001/*
002 *  Copyright 2025 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.cms.trash.element;
017
018import java.time.ZonedDateTime;
019import java.util.HashMap;
020import java.util.HashSet;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Set;
024import java.util.function.Function;
025
026import javax.jcr.Repository;
027import javax.jcr.RepositoryException;
028import javax.jcr.Session;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034
035import org.ametys.cms.ObservationConstants;
036import org.ametys.cms.trash.TrashConstants;
037import org.ametys.cms.trash.model.TrashElementModel;
038import org.ametys.core.observation.Event;
039import org.ametys.core.observation.ObservationManager;
040import org.ametys.core.user.CurrentUserProvider;
041import org.ametys.core.util.DateUtils;
042import org.ametys.plugins.repository.AmetysObject;
043import org.ametys.plugins.repository.AmetysObjectIterable;
044import org.ametys.plugins.repository.AmetysObjectResolver;
045import org.ametys.plugins.repository.AmetysRepositoryException;
046import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
047import org.ametys.plugins.repository.RemovableAmetysObject;
048import org.ametys.plugins.repository.collection.AmetysObjectCollection;
049import org.ametys.plugins.repository.collection.AmetysObjectCollectionFactory;
050import org.ametys.plugins.repository.jcr.JCRAmetysObject;
051import org.ametys.plugins.repository.jcr.NameHelper;
052import org.ametys.plugins.repository.jcr.NameHelper.NameComputationMode;
053import org.ametys.plugins.repository.provider.AbstractRepository;
054import org.ametys.plugins.repository.query.QueryHelper;
055import org.ametys.plugins.repository.query.expression.AndExpression;
056import org.ametys.plugins.repository.query.expression.BooleanExpression;
057import org.ametys.plugins.repository.query.expression.DateExpression;
058import org.ametys.plugins.repository.query.expression.Expression;
059import org.ametys.plugins.repository.query.expression.Expression.Operator;
060import org.ametys.plugins.repository.query.expression.StringExpression;
061import org.ametys.plugins.repository.trash.TrashElement;
062import org.ametys.plugins.repository.trash.TrashElementTypeExtensionPoint;
063import org.ametys.plugins.repository.trash.TrashableAmetysObject;
064import org.ametys.plugins.repository.trash.UnknownParentException;
065import org.ametys.runtime.plugin.component.AbstractLogEnabled;
066
067/**
068 * {@link TrashElementDAO} to manage {@link TrashElement}s.
069 */
070public class TrashElementDAO extends AbstractLogEnabled implements org.ametys.plugins.repository.trash.TrashElementDAO, Serviceable, Component
071{
072    /** the Ametys object resolver */
073    protected AmetysObjectResolver _resolver;
074
075    private Repository _repository;
076    private ObservationManager _observationManager;
077    private CurrentUserProvider _currentUserProvider;
078
079    private TrashElementTypeExtensionPoint _trashTypeEP;
080    
081    public void service(ServiceManager serviceManager) throws ServiceException
082    {
083        _repository = (Repository) serviceManager.lookup(AbstractRepository.ROLE);
084        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
085        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
086        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
087        _trashTypeEP = (TrashElementTypeExtensionPoint) serviceManager.lookup(TrashElementTypeExtensionPoint.ROLE);
088    }
089    
090    /**
091     * Resolve the {@link AmetysObject} into the trash workspace.
092     * @param <A> The type of the returned {@link AmetysObject}.
093     * @param ametysObjectId The identifier
094     * @return the resolved Ametys object, can be null if not found
095     */
096    public <A extends AmetysObject> A resolve(String ametysObjectId)
097    {
098        return executeInTrashSession(session -> _resolveSilently(ametysObjectId, session));
099    }
100    
101    private <A extends AmetysObject> A _resolveSilently(String ametysObjectId, Session session)
102    {
103        try
104        {
105            return _resolver.resolveById(ametysObjectId, session);
106        }
107        catch (RepositoryException e)
108        {
109            throw new AmetysRepositoryException(e);
110        }
111    }
112    
113    /**
114     * Find a trash element linked to a trashed ametys object.
115     * @param ametysObjectId The ametys object id
116     * @return the {@link TrashElement}, can be null if not found
117     */
118    public TrashElement find(String ametysObjectId)
119    {
120        return executeInTrashSession(
121            session ->
122            {
123                try
124                {
125                    Expression expression = new StringExpression(TrashElementModel.DELETED_OBJECT, Operator.EQ, ametysObjectId);
126                    String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
127                    return _resolver.<TrashElement>query(xPathQuery, session).stream().findFirst().orElse(null);
128                }
129                catch (RepositoryException e)
130                {
131                    throw new AmetysRepositoryException(e);
132                }
133            }
134        );
135    }
136    
137    /**
138     * Empty the trash.
139     * @param lifetime Number of days before the trash has to be emptied. 0 or less empty all elements.
140     * @throws AmetysRepositoryException if an error occurs
141     */
142    public void empty(long lifetime) throws AmetysRepositoryException
143    {
144        executeInTrashSession(
145            session ->
146            {
147                Expression expression = lifetime > 0
148                    ? new AndExpression(
149                        // Only delete visible trash elements. Hidden trash elements will be deleted by cascading if needed
150                        new BooleanExpression(TrashElementModel.HIDDEN, false),
151                        new DateExpression(TrashElementModel.DATE, Operator.LT, DateUtils.asDate(ZonedDateTime.now().minusDays(lifetime)))
152                    )
153                    // If lifetime is not set, we want to delete all contents in the trash
154                    : null;
155                
156                String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
157                
158                try (AmetysObjectIterable<TrashElement> trashElements = _resolver.query(xPathQuery, session))
159                {
160                    for (TrashElement trashElement : trashElements)
161                    {
162                        _remove(trashElement, lifetime > 0);
163                    }
164                }
165                catch (RepositoryException e)
166                {
167                    throw new AmetysRepositoryException(e);
168                }
169                finally
170                {
171                    session.logout();
172                }
173                
174                return null;
175            }
176        );
177    }
178
179    /**
180     * Trash the {@link AmetysObject}.
181     * @param ametysObject The Ametys object
182     * @return the {@link TrashElement} created while moving the object to the trash
183     */
184    public TrashElement trash(TrashableAmetysObject ametysObject)
185    {
186        return trash(ametysObject, false, null);
187    }
188    
189    /**
190     * Trash the {@link AmetysObject}.
191     * @param ametysObject The Ametys object
192     * @param hidden <code>true</code> if it is an object trashed by another one
193     * @param linkedObjects The list of linked objects to the current object to trash. Linked objects can be also trashed, but it is not mandatory.
194     * @return the {@link TrashElement} created while moving the object to the trash
195     */
196    public TrashElement trash(TrashableAmetysObject ametysObject, boolean hidden, String[] linkedObjects)
197    {
198        String ametysObjectId = ametysObject.getId();
199        
200        // Move the trashed node under the trash element
201        JCRAmetysObject originalParent = ametysObject.getParent();
202        TrashElement trashElement = ametysObject.moveToTrash();
203        trashElement.setHidden(hidden);
204        trashElement.addLinkedObjects(linkedObjects);
205        
206        // Save changes to trash first. That way if the save fails, the original node is not destroyed
207        trashElement.saveChanges();
208        originalParent.saveChanges();
209        
210        // Notify observers
211        Map<String, Object> eventParams = new HashMap<>();
212        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
213        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, ametysObjectId);
214        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_ADDED, _currentUserProvider.getUser(), eventParams));
215        
216        return trashElement;
217    }
218    
219    @Override
220    public TrashElement createTrashElement(TrashableAmetysObject ametysObject, String title)
221    {
222        ModifiableTraversableAmetysObject trash = getOrCreateRoot(ametysObject);
223        
224        // Create the trash element
225        String nodeName = NameHelper.getUniqueAmetysObjectName(trash, "trash", NameComputationMode.GENERATED_KEY, false);
226        TrashElement trashElement = trash.createChild(nodeName, TrashElementFactory.TRASH_ELEMENT_NODETYPE);
227        trashElement.setValue(TrashElementModel.TITLE, title);
228        trashElement.setValue(TrashElementModel.AUTHOR, _currentUserProvider.getUser());
229        trashElement.setValue(TrashElementModel.DATE, ZonedDateTime.now());
230        trashElement.setValue(TrashElementModel.DELETED_OBJECT, ametysObject.getId());
231        trashElement.setValue(TrashElementModel.PARENT_PATH, ametysObject.getParentPath());
232        trashElement.setValue(TrashElementModel.HIDDEN, false);
233        _trashTypeEP.getFirstSupportingExtension(ametysObject)
234            .ifPresentOrElse(
235                type -> trashElement.setValue(TrashElementModel.TRASH_TYPE, type.getId()),
236                () -> {
237                    // remove the node to prevent the presence of inconsistent data
238                    trashElement.remove();
239                    throw new IllegalStateException("No supporting type for trashable '" + ametysObject.getId() + "'.");
240                }
241            );
242        
243        return trashElement;
244    }
245    
246    /**
247     * Remove the {@link TrashElement} and its linked objects.
248     * @param trashElementId The trash element identifier to remove
249     */
250    public void remove(String trashElementId)
251    {
252        _remove(resolve(trashElementId), true);
253    }
254    
255    private void _remove(TrashElement trashElement, boolean removeLinkedElement)
256    {
257        // Get event param before deletion
258        Map<String, Object> eventParams = new HashMap<>();
259        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
260        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, trashElement.getAmetysObjectId());
261        
262        if (removeLinkedElement)
263        {
264            // Remove linked objects
265            trashElement.getLinkedElements(true).forEach(e -> _remove(e, true));
266        }
267        
268        // Remove the trash element itself
269        _removeSingleObject(trashElement);
270        
271        // Notify observers
272        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_DELETED, _currentUserProvider.getUser(), eventParams));
273    }
274    
275    private void _removeSingleObject(RemovableAmetysObject ametysObject)
276    {
277        JCRAmetysObject parent = ametysObject.getParent();
278        ametysObject.remove();
279        parent.saveChanges();
280    }
281    
282    /**
283     * Restore the {@link TrashElement} and its linked objects.
284     * @param trashElementId The trash element identifier to restore
285     * @return a report of the operation
286     * @throws UnknownParentException if its not possible to obtains a parent to restore into
287     */
288    public RestorationReport restore(String trashElementId) throws UnknownParentException
289    {
290        TrashElement trashElement = resolve(trashElementId);
291        
292        // Restore the object and all its linked objects
293        Map<String, TrashableAmetysObject> result = _restore(trashElement);
294        
295        // Perform additional action now that all object are restored
296        for (TrashableAmetysObject restoredObject : result.values())
297        {
298            _trashTypeEP.getFirstSupportingExtension(restoredObject)
299                .ifPresent(type -> type.additionnalRestoreAction(restoredObject));
300        }
301        
302        // Then notify after all the restoration to unsure that all reference between restored elements are valid
303        Set<TrashableAmetysObject> restoredLinkedObject = new HashSet<>();
304        for (Entry<String, TrashableAmetysObject> entry : result.entrySet())
305        {
306            String elementId = entry.getKey();
307            TrashableAmetysObject restoredObject = entry.getValue();
308            
309            // Notify observers
310            Map<String, Object> eventParams = new HashMap<>();
311            eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, elementId);
312            eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, restoredObject.getId());
313            _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_RESTORED, _currentUserProvider.getUser(), eventParams));
314            
315            // add to the linked object set if its not the original
316            if (!elementId.equals(trashElementId))
317            {
318                restoredLinkedObject.add(restoredObject);
319            }
320        }
321        
322        return new RestorationReport(result.get(trashElementId), restoredLinkedObject);
323    }
324    
325    /**
326     * Restore a trash element, and its hidden linked element (if available) recursively
327     * @param trashElement the trash element to restore
328     * @return a map with the ids of the restored trash element as key and the restored trashables as value
329     */
330    private Map<String, TrashableAmetysObject> _restore(TrashElement trashElement) throws UnknownParentException
331    {
332        Map<String, TrashableAmetysObject> result = new HashMap<>();
333        
334        String trashElementId = trashElement.getId();
335        String trashableAOId = trashElement.getAmetysObjectId();
336        TrashableAmetysObject trashableAO = resolve(trashableAOId);
337        
338        // We first restored linked object because the page implementation has a physical reference to its contents.
339        // So if we restored the page before the contents, we have an exception from the repository.
340        // It seems to be unavoidable.
341        
342        Set<TrashElement> linkedElements = trashElement.getLinkedElements(true);
343        
344        for (TrashElement linkedElement: linkedElements)
345        {
346            try
347            {
348                result.putAll(_restore(linkedElement));
349            }
350            catch (UnknownParentException e)
351            {
352                // Do not prevent restoring the original element because
353                // a linked element failed
354                getLogger().warn("An error prevented to restore the element '{}' that is linked to the restoration of '{}'", linkedElement.getId(), trashableAO.getId());
355            }
356        }
357        
358        // Restore the current trash element
359        TrashableAmetysObject restoredAO = trashableAO.restoreFromTrash();
360        restoredAO.saveChanges();
361        
362        result.put(trashElementId, restoredAO);
363        
364        // Remove the trash element
365        _removeSingleObject(trashElement);
366        
367        return result;
368    }
369    
370    /**
371     * Get or create the trash root
372     * @param ametysObject the object that need to be trashed
373     * @return the trash where the ametys will be trashed
374     */
375    protected ModifiableTraversableAmetysObject getOrCreateRoot(TrashableAmetysObject ametysObject)
376    {
377        return executeInTrashSession(
378            session ->
379            {
380                ModifiableTraversableAmetysObject root = _resolver.resolveByPath("/", session);
381                return getOrCreateCollection(root, "ametys-internal:trash");
382            }
383        );
384    }
385    
386    /**
387     * Get or create an Ametys object collection on the given parent
388     * @param parent The parent
389     * @param collectionName The collection name
390     * @return The {@link AmetysObjectCollection}
391     */
392    protected ModifiableTraversableAmetysObject getOrCreateCollection(ModifiableTraversableAmetysObject parent, String collectionName)
393    {
394        if (parent.hasChild(collectionName))
395        {
396            return parent.getChild(collectionName);
397        }
398        else
399        {
400            ModifiableTraversableAmetysObject child = parent.createChild(collectionName, AmetysObjectCollectionFactory.COLLECTION_NODETYPE);
401            parent.saveChanges();
402            return child;
403        }
404    }
405    
406    
407    /**
408     * Execute a function into the trash workspace.
409     * 
410     * This method only handles the creation of the session.
411     * The caller is responsible for logging out (either in the function itself or by using the returned value).
412     * 
413     * @param <A> The returned type, can be anything. If it is {@link Void}, the function should return <code>null</code>
414     * @param functionToExecute The function to execute, it takes a {@link Session} as parameter
415     * @return The result of the function
416     */
417    protected <A> A executeInTrashSession(Function<Session, A> functionToExecute)
418    {
419        Session trashSession = null;
420        try
421        {
422            // Login the trash workspace
423            trashSession = _repository.login(TrashConstants.TRASH_WORKSPACE);
424            
425            // Execute the function with the trash session
426            return functionToExecute.apply(trashSession);
427        }
428        catch (RepositoryException e)
429        {
430            throw new AmetysRepositoryException(e);
431        }
432    }
433
434    /**
435     * Describe the result of a restore operation.
436     * The report contains :
437     * <ul>
438     * <li>the restored object
439     * <li>a list of all the object restored with it
440     * </ul>
441     * @param restoredObject the restored object
442     * @param restoredLinkedObject the object restored along the {@code restoredObject}
443     */
444    public record RestorationReport(TrashableAmetysObject restoredObject, Set<TrashableAmetysObject> restoredLinkedObject) { }
445}