001/*
002 * Copyright (c) 2006-2011 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the Eclipse Public License v1.0
006 * which accompanies this distribution, and is available at
007 * http://www.eclipse.org/legal/epl-v10.html
008 *
009 * Contributors:
010 *     Thierry Delprat
011 *     Florent Guillaume
012 */
013package org.nuxeo.ecm.core.trash;
014
015import java.io.Serializable;
016import java.security.Principal;
017import java.util.ArrayList;
018import java.util.Collections;
019import java.util.Comparator;
020import java.util.HashSet;
021import java.util.LinkedList;
022import java.util.List;
023import java.util.Set;
024import java.util.regex.Matcher;
025import java.util.regex.Pattern;
026
027import org.apache.commons.logging.Log;
028import org.apache.commons.logging.LogFactory;
029import org.nuxeo.common.utils.Path;
030import org.nuxeo.ecm.core.api.CoreSession;
031import org.nuxeo.ecm.core.api.DocumentModel;
032import org.nuxeo.ecm.core.api.DocumentModelList;
033import org.nuxeo.ecm.core.api.DocumentRef;
034import org.nuxeo.ecm.core.api.DocumentSecurityException;
035import org.nuxeo.ecm.core.api.LifeCycleConstants;
036import org.nuxeo.ecm.core.api.Lock;
037import org.nuxeo.ecm.core.api.NuxeoPrincipal;
038import org.nuxeo.ecm.core.api.PathRef;
039import org.nuxeo.ecm.core.api.event.CoreEventConstants;
040import org.nuxeo.ecm.core.api.event.DocumentEventCategories;
041import org.nuxeo.ecm.core.api.security.SecurityConstants;
042import org.nuxeo.ecm.core.event.Event;
043import org.nuxeo.ecm.core.event.EventService;
044import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
045import org.nuxeo.runtime.api.Framework;
046import org.nuxeo.runtime.model.DefaultComponent;
047
048public class TrashServiceImpl extends DefaultComponent implements TrashService {
049
050    private static final Log log = LogFactory.getLog(TrashServiceImpl.class);
051
052    @Override
053    public boolean folderAllowsDelete(DocumentModel folder) {
054        return folder.getCoreSession().hasPermission(folder.getRef(), SecurityConstants.REMOVE_CHILDREN);
055    }
056
057    @Override
058    public boolean checkDeletePermOnParents(List<DocumentModel> docs) {
059        if (docs.isEmpty()) {
060            return false;
061        }
062        CoreSession session = docs.get(0).getCoreSession();
063        for (DocumentModel doc : docs) {
064            if (session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) {
065                return true;
066            }
067        }
068        return false;
069    }
070
071    @Override
072    public boolean canDelete(List<DocumentModel> docs, Principal principal, boolean checkProxies)
073            {
074        if (docs.isEmpty()) {
075            return false;
076        }
077        // used to do only check on parent perm
078        TrashInfo info = getInfo(docs, principal, checkProxies, false);
079        return info.docs.size() > 0;
080    }
081
082    @Override
083    public boolean canPurgeOrUndelete(List<DocumentModel> docs, Principal principal) {
084        if (docs.isEmpty()) {
085            return false;
086        }
087        // used to do only check on parent perm
088        TrashInfo info = getInfo(docs, principal, false, true);
089        return info.docs.size() == docs.size();
090    }
091
092    public boolean canUndelete(List<DocumentModel> docs) {
093        if (docs.isEmpty()) {
094            return false;
095        }
096        // used to do only check on parent perm
097        TrashInfo info = getInfo(docs, null, false, true);
098        return info.docs.size() > 0;
099    }
100
101    protected TrashInfo getInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies,
102            boolean checkDeleted) {
103        TrashInfo info = new TrashInfo();
104        info.docs = new ArrayList<DocumentModel>(docs.size());
105        if (docs.isEmpty()) {
106            return info;
107        }
108        CoreSession session = docs.get(0).getCoreSession();
109        for (DocumentModel doc : docs) {
110            if (checkDeleted && !LifeCycleConstants.DELETED_STATE.equals(doc.getCurrentLifeCycleState())) {
111                info.forbidden++;
112                continue;
113            }
114            if (doc.getParentRef() == null) {
115                if (doc.isVersion() && !session.getProxies(doc.getRef(), null).isEmpty()) {
116                    // do not remove versions used by proxies
117                    info.forbidden++;
118                    continue;
119                }
120
121            } else {
122                if (!session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) {
123                    info.forbidden++;
124                    continue;
125                }
126            }
127            if (!session.hasPermission(doc.getRef(), SecurityConstants.REMOVE)) {
128                info.forbidden++;
129                continue;
130            }
131            if (checkProxies && doc.isProxy()) {
132                info.proxies++;
133                continue;
134            }
135            if (doc.isLocked()) {
136                String locker = getDocumentLocker(doc);
137                if (principal == null
138                        || (principal instanceof NuxeoPrincipal && ((NuxeoPrincipal) principal).isAdministrator())
139                        || principal.getName().equals(locker)) {
140                    info.docs.add(doc);
141                } else {
142                    info.locked++;
143                }
144            } else {
145                info.docs.add(doc);
146            }
147        }
148        return info;
149    }
150
151    protected static String getDocumentLocker(DocumentModel doc) {
152        Lock lock = doc.getLockInfo();
153        return lock == null ? null : lock.getOwner();
154    }
155
156    /**
157     * Path-based comparator used to put folders before their children.
158     */
159    protected static class PathComparator implements Comparator<DocumentModel>, Serializable {
160
161        private static final long serialVersionUID = 1L;
162
163        public static PathComparator INSTANCE = new PathComparator();
164
165        @Override
166        public int compare(DocumentModel doc1, DocumentModel doc2) {
167            return doc1.getPathAsString().replace("/", "\u0000").compareTo(
168                    doc2.getPathAsString().replace("/", "\u0000"));
169        }
170
171    }
172
173    @Override
174    public TrashInfo getTrashInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies,
175            boolean checkDeleted) {
176        TrashInfo info = getInfo(docs, principal, checkProxies, checkDeleted);
177        // Keep only common tree roots (see NXP-1411)
178        // This is not strictly necessary with Nuxeo Core >= 1.3.2
179        Collections.sort(info.docs, PathComparator.INSTANCE);
180        List<DocumentModel> roots = new LinkedList<DocumentModel>();
181        info.rootPaths = new HashSet<Path>();
182        info.rootRefs = new LinkedList<DocumentRef>();
183        info.rootParentRefs = new HashSet<DocumentRef>();
184        Path previousPath = null;
185        for (DocumentModel doc : info.docs) {
186            if (previousPath == null || !previousPath.isPrefixOf(doc.getPath())) {
187                roots.add(doc);
188                Path path = doc.getPath();
189                info.rootPaths.add(path);
190                info.rootRefs.add(doc.getRef());
191                if (doc.getParentRef() != null) {
192                    info.rootParentRefs.add(doc.getParentRef());
193                }
194                previousPath = path;
195            }
196        }
197        return info;
198    }
199
200    @Override
201    public DocumentModel getAboveDocument(DocumentModel doc, Set<Path> rootPaths) {
202        CoreSession session = doc.getCoreSession();
203        while (underOneOf(doc.getPath(), rootPaths)) {
204            doc = session.getParentDocument(doc.getRef());
205        }
206        return doc;
207    }
208
209    protected static boolean underOneOf(Path testedPath, Set<Path> paths) {
210        for (Path path : paths) {
211            if (path != null && path.isPrefixOf(testedPath)) {
212                return true;
213            }
214        }
215        return false;
216    }
217
218    @Override
219    public void trashDocuments(List<DocumentModel> docs) {
220        if (docs.isEmpty()) {
221            return;
222        }
223        CoreSession session = docs.get(0).getCoreSession();
224        for (DocumentModel doc : docs) {
225            DocumentRef docRef = doc.getRef();
226            if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.DELETE_TRANSITION)
227                    && !doc.isProxy()) {
228                if (!session.canRemoveDocument(docRef)) {
229                    throw new DocumentSecurityException("User " + session.getPrincipal().getName()
230                            + " does not have the permission to remove the document " + doc.getId() + " ("
231                            + doc.getPath() + ")");
232                }
233                trashDocument(session, doc);
234            } else if (session.getCurrentLifeCycleState(docRef).equals(LifeCycleConstants.DELETED_STATE)) {
235                log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state "
236                        + doc.getCurrentLifeCycleState() + " is already in state "
237                        + LifeCycleConstants.DELETED_STATE + ", nothing to do");
238                return;
239            } else {
240                log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state "
241                        + doc.getCurrentLifeCycleState() + " does not support transition "
242                        + LifeCycleConstants.DELETE_TRANSITION + ", it will be deleted immediately");
243                session.removeDocument(docRef);
244            }
245        }
246        session.save();
247    }
248
249    @Override
250    public void purgeDocuments(CoreSession session, List<DocumentRef> docRefs) {
251        if (docRefs.isEmpty()) {
252            return;
253        }
254        session.removeDocuments(docRefs.toArray(new DocumentRef[docRefs.size()]));
255        session.save();
256    }
257
258    @Override
259    public Set<DocumentRef> undeleteDocuments(List<DocumentModel> docs) {
260        Set<DocumentRef> undeleted = new HashSet<DocumentRef>();
261        if (docs.isEmpty()) {
262            return undeleted;
263        }
264        CoreSession session = docs.get(0).getCoreSession();
265        Set<DocumentRef> docRefs = undeleteDocumentList(session, docs);
266        undeleted.addAll(docRefs);
267        // undeleted ancestors
268        for (DocumentRef docRef : docRefs) {
269            undeleteAncestors(session, docRef, undeleted);
270        }
271        session.save();
272        // find parents of undeleted docs (for notification);
273        Set<DocumentRef> parentRefs = new HashSet<DocumentRef>();
274        for (DocumentRef docRef : undeleted) {
275            parentRefs.add(session.getParentDocumentRef(docRef));
276        }
277        // launch async action on folderish to undelete all children recursively
278        for (DocumentModel doc : docs) {
279            if (doc.isFolder()) {
280                notifyEvent(session, LifeCycleConstants.DOCUMENT_UNDELETED, doc);
281            }
282        }
283        return parentRefs;
284    }
285
286    protected void notifyEvent(CoreSession session, String eventId, DocumentModel doc) {
287        DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), doc);
288        ctx.setCategory(DocumentEventCategories.EVENT_DOCUMENT_CATEGORY);
289        ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, session.getRepositoryName());
290        ctx.setProperty(CoreEventConstants.SESSION_ID, session.getSessionId());
291        Event event = ctx.newEvent(eventId);
292        event.setInline(false);
293        event.setImmediate(true);
294        EventService eventService = Framework.getLocalService(EventService.class);
295        eventService.fireEvent(event);
296    }
297
298    /**
299     * Undeletes a list of documents. Session is not saved. Log about non-deletable documents.
300     */
301    protected Set<DocumentRef> undeleteDocumentList(CoreSession session, List<DocumentModel> docs)
302            {
303        Set<DocumentRef> undeleted = new HashSet<DocumentRef>();
304        for (DocumentModel doc : docs) {
305            DocumentRef docRef = doc.getRef();
306            if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) {
307                undeleteDocument(session, doc);
308                undeleted.add(docRef);
309            } else {
310                log.debug("Impossible to undelete document " + docRef + " as it does not support transition "
311                        + LifeCycleConstants.UNDELETE_TRANSITION);
312            }
313        }
314        return undeleted;
315    }
316
317    /**
318     * Undeletes ancestors of a document. Session is not saved. Stops as soon as an ancestor is not undeletable.
319     */
320    protected void undeleteAncestors(CoreSession session, DocumentRef docRef, Set<DocumentRef> undeleted)
321            {
322        for (DocumentRef ancestorRef : session.getParentDocumentRefs(docRef)) {
323            // getting allowed state transitions and following a transition need
324            // ReadLifeCycle and WriteLifeCycle
325            if (session.hasPermission(ancestorRef, SecurityConstants.READ_LIFE_CYCLE)
326                    && session.hasPermission(ancestorRef, SecurityConstants.WRITE_LIFE_CYCLE)) {
327                if (session.getAllowedStateTransitions(ancestorRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) {
328                    DocumentModel ancestor = session.getDocument(ancestorRef);
329                    undeleteDocument(session, ancestor);
330                    undeleted.add(ancestorRef);
331                } else {
332                    break;
333                }
334            } else {
335                // stop if lifecycle properties can't be read on an ancestor
336                log.debug("Stopping to restore ancestors because " + ancestorRef.toString() + " is not readable");
337                break;
338            }
339        }
340    }
341
342    /**
343     * Matches names of documents in the trash, created by {@link #trashDocument}.
344     */
345    protected static final Pattern TRASHED_PATTERN = Pattern.compile("(.*)\\._[0-9]{13,}_\\.trashed");
346
347    /**
348     * Matches names resulting from a collision, suffixed with a time in milliseconds, created by DuplicatedNameFixer.
349     * We also attempt to remove this when getting a doc out of the trash.
350     */
351    protected static final Pattern COLLISION_PATTERN = Pattern.compile("(.*)\\.[0-9]{13,}");
352
353    @Override
354    public String mangleName(DocumentModel doc) {
355        return doc.getName() + "._" + System.currentTimeMillis() + "_.trashed";
356    }
357
358    @Override
359    public String unmangleName(DocumentModel doc) {
360        String name = doc.getName();
361        Matcher matcher = TRASHED_PATTERN.matcher(name);
362        if (matcher.matches() && matcher.group(1).length() > 0) {
363            name = matcher.group(1);
364            matcher = COLLISION_PATTERN.matcher(name);
365            if (matcher.matches() && matcher.group(1).length() > 0) {
366                @SuppressWarnings("resource")
367                CoreSession session = doc.getCoreSession();
368                if (session != null) {
369                    String orig = matcher.group(1);
370                    String parentPath = session.getDocument(doc.getParentRef()).getPathAsString();
371                    if (parentPath.equals("/")) {
372                        parentPath = ""; // root
373                    }
374                    String newPath = parentPath + "/" + orig;
375                    if (!session.exists(new PathRef(newPath))) {
376                        name = orig;
377                    }
378                }
379            }
380        }
381        return name;
382    }
383
384    protected void trashDocument(CoreSession session, DocumentModel doc) {
385        String name = mangleName(doc);
386        session.move(doc.getRef(), doc.getParentRef(), name);
387        session.followTransition(doc, LifeCycleConstants.DELETE_TRANSITION);
388    }
389
390    protected void undeleteDocument(CoreSession session, DocumentModel doc) {
391        String name = doc.getName();
392        String newName = unmangleName(doc);
393        if (!newName.equals(name)) {
394            session.move(doc.getRef(), doc.getParentRef(), newName);
395        }
396        session.followTransition(doc, LifeCycleConstants.UNDELETE_TRANSITION);
397    }
398
399    /**
400     * {@inheritDoc}
401     */
402    @Override
403    public DocumentModelList getDocuments(DocumentModel currentDoc) {
404        CoreSession session = currentDoc.getCoreSession();
405        DocumentModelList docs = session.query(
406                String.format("SELECT * FROM " + "Document WHERE " + "ecm:mixinType != 'HiddenInNavigation' AND "
407                        + "ecm:isCheckedInVersion = 0 AND ecm:currentLifeCycleState = "
408                        + "'deleted' AND ecm:parentId = '%s'", currentDoc.getId()));
409        return docs;
410    }
411}