001/*
002 *  Copyright 2019 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.plugins.userdirectory;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Set;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.configuration.Configurable;
028import org.apache.avalon.framework.configuration.Configuration;
029import org.apache.avalon.framework.configuration.ConfigurationException;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.slf4j.Logger;
034
035import org.ametys.cms.ObservationConstants;
036import org.ametys.cms.clientsideelement.content.SmartContentClientSideElementHelper;
037import org.ametys.cms.indexing.solr.SolrIndexHelper;
038import org.ametys.cms.repository.Content;
039import org.ametys.cms.trash.element.TrashElementDAO;
040import org.ametys.cms.trash.model.TrashElementModel;
041import org.ametys.core.observation.Event;
042import org.ametys.core.observation.ObservationManager;
043import org.ametys.core.user.CurrentUserProvider;
044import org.ametys.plugins.repository.AmetysObjectResolver;
045import org.ametys.plugins.repository.ModifiableAmetysObject;
046import org.ametys.plugins.repository.RemovableAmetysObject;
047import org.ametys.plugins.repository.lock.LockableAmetysObject;
048import org.ametys.plugins.repository.trash.TrashElement;
049import org.ametys.plugins.repository.trash.TrashableAmetysObject;
050
051/**
052 * Delete UD content component
053 */
054public abstract class AbstractDeleteUDContentComponent implements Component, Serviceable, Configurable
055{
056    private static final int _REMOVE_REFERENCE_DEFAULT_ACTION_ID = 200;
057    
058    /** The Ametys object resolver */
059    protected AmetysObjectResolver _resolver;
060    /** The observation manager */
061    protected ObservationManager _observationManager;
062    /** The current user provider */
063    protected CurrentUserProvider _currentUserProvider;
064    /** Helper for smart content client elements */
065    protected SmartContentClientSideElementHelper _smartHelper;
066    /** The solr index helper */
067    protected SolrIndexHelper _solrIndexHelper;
068    /** The trash element DAO */
069    protected TrashElementDAO _trashElementDAO;
070    
071    /** The action id to call when references are removed */
072    protected int _removeReferenceActionId;
073    
074    @Override
075    public void service(ServiceManager smanager) throws ServiceException
076    {
077        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
078        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
079        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
080        _smartHelper = (SmartContentClientSideElementHelper) smanager.lookup(SmartContentClientSideElementHelper.ROLE);
081        _solrIndexHelper = (SolrIndexHelper) smanager.lookup(SolrIndexHelper.ROLE);
082        _trashElementDAO = (TrashElementDAO) smanager.lookup(org.ametys.plugins.repository.trash.TrashElementDAO.ROLE);
083    }
084    
085    @Override
086    public void configure(Configuration configuration) throws ConfigurationException
087    {
088        Configuration conf = configuration.getChild("removeReferenceActionId");
089        _removeReferenceActionId = conf.getValueAsInteger(_REMOVE_REFERENCE_DEFAULT_ACTION_ID);
090    }
091
092    
093    /**
094     * Trash contents if possible or delete it and logs results
095     * @param contentsToRemove the list of contents to remove
096     * @param parameters the additional parameters
097     * @param rights the map of rights id with its prefix
098     * @param logger The logger
099     * @return the number of deleted contents
100     */
101    public int trashContentsWithLog(List<Content> contentsToRemove, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
102    {
103        return _deleteContentsWithLog(contentsToRemove, parameters, rights, false, logger);
104    }
105
106    
107    /**
108     * Delete contents and logs results
109     * @param contentsToRemove the list of contents to remove
110     * @param parameters the additional parameters
111     * @param rights the map of rights id with its prefix
112     * @param logger The logger
113     * @return the number of deleted contents
114     */
115    public int deleteContentsWithLog(List<Content> contentsToRemove, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
116    {
117        return _deleteContentsWithLog(contentsToRemove, parameters, rights, true, logger);
118    }
119    
120    /**
121     * Delete contents and logs results
122     * @param contentsToRemove the list of contents to remove
123     * @param parameters the additional parameters
124     * @param rights the map of rights id with its prefix
125     * @param logger The logger
126     * @param onlyDeletion <code>true</code> to really delete the contents, otherwise the contents will be trashed if trashable
127     * @return the number of deleted contents
128     */
129    @SuppressWarnings("unchecked")
130    private int _deleteContentsWithLog(List<Content> contentsToRemove, Map<String, Object> parameters, Map<String, String> rights, boolean onlyDeletion, Logger logger)
131    {
132        int nbDeletedContents = 0;
133        
134        List<String> contentIds = contentsToRemove.stream()
135                .map(Content::getId)
136                .collect(Collectors.toList());
137        
138        logger.info("Trying to delete contents. This can take a while...");
139        Map<String, Object> deleteResults = _deleteContents(contentIds, parameters, rights, onlyDeletion, logger);
140        logger.info("Contents deleting process ended.");
141        
142        for (String contentId : contentIds)
143        {
144            Map<String, Object> result = (Map<String, Object>) deleteResults.get(contentId);
145            if (result != null) // if the result is null, the content was already deleted because it's a child of a previous deleted content
146            {
147                List<String> deletedContents = (List<String>) result.get("deleted-contents");
148                nbDeletedContents += deletedContents.size();
149                
150                List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
151                if (referencedContents.size() > 0)
152                {
153                    logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
154                }
155                
156                List<Content> lockedContents = (List<Content>) result.get("locked-contents");
157                if (lockedContents.size() > 0)
158                {
159                    logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
160                }
161                
162                List<Content> undeletedContents = (List<Content>) result.get("undeleted-contents");
163                if (undeletedContents.size() > 0)
164                {
165                    logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
166                }
167            }
168        }
169        
170        return nbDeletedContents;
171    }
172
173    /**
174     * Trash contents if possible, or delete it.
175     * @param contentsId The ids of contents to delete
176     * @param parameters the additional parameters
177     * @param rights the map of rights id with its prefix
178     * @param logger The logger
179     * @return the deleted and undeleted contents
180     */
181    public Map<String, Object> trashContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
182    {
183        return _deleteContents(contentsId, parameters, rights, false, logger);
184    }
185
186    /**
187     * Delete contents
188     * @param contentsId The ids of contents to delete
189     * @param parameters the additional parameters
190     * @param rights the map of rights id with its prefix
191     * @param logger The logger
192     * @return the deleted and undeleted contents
193     */
194    public Map<String, Object> deleteContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
195    {
196        return _deleteContents(contentsId, parameters, rights, true, logger);
197    }
198    
199    /**
200     * Delete contents
201     * @param contentsId The ids of contents to delete
202     * @param parameters the additional parameters
203     * @param rights the map of rights id with its prefix
204     * @param onlyDeletion <code>true</code> to really delete the contents, otherwise the contents will be trashed if trashable
205     * @param logger The logger
206     * @return the deleted and undeleted contents
207     */
208    private Map<String, Object> _deleteContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights, boolean onlyDeletion, Logger logger)
209    {
210        Map<String, Object> results = new HashMap<>();
211        
212        List<String> alreadyDeletedContentIds = new ArrayList<>();
213        for (String contentId : contentsId)
214        {
215            if (!alreadyDeletedContentIds.contains(contentId))
216            {
217                Content content = _resolver.resolveById(contentId);
218                
219                Map<String, Object> result = new HashMap<>();
220                result.put("deleted-contents", new ArrayList<>());
221                result.put("undeleted-contents", new ArrayList<>());
222                result.put("referenced-contents", new ArrayList<>());
223                result.put("unauthorized-contents", new ArrayList<>());
224                result.put("locked-contents", new ArrayList<>());
225                result.put("initial-content", content.getId());
226                results.put(contentId, result);
227                
228                boolean referenced = isContentReferenced(content, logger);
229                if (referenced || !_checkBeforeDeletion(content, rights, result, logger))
230                {
231                    if (referenced)
232                    {
233                        // Indicate that the content is referenced.
234                        @SuppressWarnings("unchecked")
235                        List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
236                        referencedContents.add(content);
237                    }
238                    result.put("check-before-deletion-failed", true);
239                }
240                else
241                {
242                    // Process deletion
243                    _deleteContent(content, parameters, rights, result, onlyDeletion, logger);
244
245                    @SuppressWarnings("unchecked")
246                    List<String> deletedContents = (List<String>) result.get("deleted-contents");
247                    if (deletedContents != null)
248                    {
249                        alreadyDeletedContentIds.addAll(deletedContents);
250                    }
251                }
252            }
253            else
254            {
255                logger.info("Content with id '{}' has been already deleted during its parent deletion", contentId);
256                
257                // make the trash element visible as it was explicitly deleted by the user
258                TrashElement trashElement = _trashElementDAO.find(contentId);
259                if (trashElement != null && trashElement.<Boolean>getValue(TrashElementModel.HIDDEN))
260                {
261                    trashElement.setHidden(false);
262                    trashElement.saveChanges();
263                    
264                    // Notify observers
265                    Map<String, Object> eventParams = new HashMap<>();
266                    eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
267                    eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, contentId);
268                    _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_UPDATED, _currentUserProvider.getUser(), eventParams));
269                }
270            }
271        }
272
273        return results;
274    }
275
276    /**
277     * Delete one content
278     * @param content the content to delete
279     * @param parameters the additional parameters
280     * @param rights the map of rights id with its prefix
281     * @param results the results map
282     * @param onlyDeletion <code>true</code> to really delete the content, otherwise the content will be trashed if trashable
283     * @param logger The logger
284     */
285    protected void _deleteContent(Content content, Map<String, Object> parameters, Map<String, String> rights, Map<String, Object> results, boolean onlyDeletion, Logger logger)
286    {
287        // 1 - First remove relations
288        boolean success = _removeRelations(content, parameters, logger);
289        
290        // 2 - If succeed, process to deletion
291        if (success)
292        {
293            _processContentDeletion(content, parameters, rights, results, onlyDeletion, logger);
294        }
295        else
296        {
297            @SuppressWarnings("unchecked")
298            List<Content> undeletedContents = (List<Content>) results.get("undeleted-contents");
299            undeletedContents.add(content);
300            
301            logger.warn("Can not delete content {} ('{}') : at least one relation to contents could not be removed", content.getTitle(), content.getId());
302        }
303    }
304
305    /**
306     * Delete one content
307     * @param content the content to delete
308     * @param parameters the additional parameters
309     * @param rights the map of rights id with its prefix
310     * @param results the results map
311     * @param onlyDeletion <code>true</code> to really delete the content, otherwise the content will be trashed if trashable
312     * @param logger The logger
313     */
314    @SuppressWarnings("unchecked")
315    protected void _processContentDeletion(Content content, Map<String, Object> parameters, Map<String, String> rights, Map<String, Object> results, boolean onlyDeletion, Logger logger)
316    {
317        Set<DeletionInfo> toDelete = _getContentIdsToDelete(content, parameters, rights, false, results, logger);
318        
319        List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
320        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
321        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
322        
323        if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0)
324        {
325            _finalizeDeleteContents(toDelete, content.getParent(), results, onlyDeletion, logger);
326        }
327    }
328    
329    /**
330     * Finalize the deletion of contents. Call observers and remove contents
331     * @param contentIdsToDelete the list of content id to delete
332     * @param parent the jcr parent for saving changes
333     * @param results the results map
334     * @param onlyDeletion <code>true</code> to really delete the contents, otherwise the contents will be trashed if trashable
335     * @param logger The logger
336     */
337    protected void _finalizeDeleteContents(Set<DeletionInfo> contentIdsToDelete, ModifiableAmetysObject parent, Map<String, Object> results, boolean onlyDeletion, Logger logger)
338    {
339        @SuppressWarnings("unchecked")
340        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
341        @SuppressWarnings("unchecked")
342        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
343        
344        if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty())
345        {
346            //Do Nothing
347            return;
348        }
349        
350        try
351        {
352            _solrIndexHelper.pauseSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED});
353            
354            Map<String, Map<String, Object>> eventParams = new HashMap<>();
355            for (DeletionInfo info : contentIdsToDelete)
356            {
357                Content content = _resolver.resolveById(info.contentId());
358                Map<String, Object> eventParam = _getEventParametersForDeletion(content);
359                eventParams.put(info.contentId(), eventParam);
360                
361                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam));
362            }
363            
364            for (DeletionInfo info : contentIdsToDelete)
365            {
366                Content content = _resolver.resolveById(info.contentId());
367                
368                // Remove the content.
369                LockableAmetysObject lockedContent = (LockableAmetysObject) content;
370                if (lockedContent.isLocked())
371                {
372                    lockedContent.unlock();
373                }
374                
375                if (!onlyDeletion && content instanceof TrashableAmetysObject trashableAO)
376                {
377                    _trashElementDAO.trash(trashableAO, info.hidden(), info.linkedContents().toArray(String[]::new));
378                }
379                else
380                {
381                    ((RemovableAmetysObject) content).remove();
382                }
383            }
384            
385            parent.saveChanges();
386            
387            for (DeletionInfo info : contentIdsToDelete)
388            {
389                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(info.contentId())));
390                
391                @SuppressWarnings("unchecked")
392                List<String> deletedContents = (List<String>) results.get("deleted-contents");
393                deletedContents.add(info.contentId());
394            }
395        }
396        finally
397        {
398            _solrIndexHelper.restartSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED});
399        }
400    }
401    
402    /**
403     * True if we can delete the content (check if removable, rights and if locked)
404     * @param content the content
405     * @param rights the map of rights id with its prefix
406     * @param results the results map
407     * @return true if we can delete the content
408     */
409    protected boolean _canDeleteContent(Content content, Map<String, String> rights, Map<String, Object> results)
410    {
411        if (!(content instanceof RemovableAmetysObject))
412        {
413            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
414        }
415        
416        if (!_hasRight(content, rights))
417        {
418            // User has no sufficient right
419            @SuppressWarnings("unchecked")
420            List<Content> norightContents = (List<Content>) results.get("unauthorized-contents");
421            norightContents.add(content);
422            
423            return false;
424        }
425        else if (_isLocked(content))
426        {
427            @SuppressWarnings("unchecked")
428            List<Content> lockedContents = (List<Content>) results.get("locked-contents");
429            lockedContents.add(content);
430            
431            return false;
432        }
433        
434        return true;
435    }
436    
437    /**
438     * Get parameters for content deleted {@link Event}
439     * @param content the removed content
440     * @return the event's parameters
441     */
442    protected Map<String, Object> _getEventParametersForDeletion (Content content)
443    {
444        Map<String, Object> eventParams = new HashMap<>();
445        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
446        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
447        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
448        return eventParams;
449    }
450    
451    /**
452     * Determines if the content is locked
453     * @param content the content
454     * @return true if the content is locked
455     */
456    protected boolean _isLocked(Content content)
457    {
458        return _smartHelper.isLocked(content);
459    }
460    
461    /**
462     * Determines if the user has sufficient right for the given content
463     * @param content the content
464     * @param rights the map of rights id with its prefix
465     * @return true if user has sufficient right
466     */
467    protected boolean _hasRight(Content content, Map<String, String> rights)
468    {
469        if (rights.isEmpty())
470        {
471            return true;
472        }
473        
474        return _smartHelper.hasRight(rights, content);
475    }
476    
477    /**
478     * True if the content is referenced
479     * @param content the content
480     * @param logger The logger
481     * @return true if the content is referenced
482     */
483    public abstract boolean isContentReferenced(Content content, Logger logger);
484
485    /**
486     * Check that deletion can be performed without blocking errors
487     * @param content The initial content to delete
488     * @param rights the map of rights id with its prefix
489     * @param results The results
490     * @param logger The logger
491     * @return true if the deletion can be performed
492     */
493    protected abstract boolean _checkBeforeDeletion(Content content, Map<String, String> rights, Map<String, Object> results, Logger logger);
494    
495    /**
496     * Remove relations
497     * @param content the content
498     * @param parameters the additional parameters
499     * @param logger The logger
500     * @return <code>true</code> if all relations have been removed
501     */
502    protected abstract boolean _removeRelations(Content content, Map<String, Object> parameters, Logger logger);
503    
504    /**
505     * Get the id of children to be deleted.
506     * All children shared with other contents which are not part of deletion, will be not deleted.
507     * @param content The content to delete
508     * @param parameters the additional parameters
509     * @param rights the map of rights id with its prefix
510     * @param hidden to hide the trash element
511     * @param results The results
512     * @param logger The logger
513     * @return The id of contents to be deleted
514     */
515    protected abstract Set<DeletionInfo> _getContentIdsToDelete (Content content, Map<String, Object> parameters, Map<String, String> rights, boolean hidden, Map<String, Object> results, Logger logger);
516    
517    /**
518     * Store information related to a deletion operation to perform
519     * @param contentId the id to delete
520     * @param linkedContents the list of content ids that will be deleted because of the deletion of this content
521     * @param hidden to hide the trash element
522     */
523    protected record DeletionInfo(String contentId, Collection<String> linkedContents, boolean hidden) { }
524}