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