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        }
213        return doc;
214    }
215
216    protected static boolean underOneOf(Path testedPath, Set<Path> paths) {
217        for (Path path : paths) {
218            if (path != null && path.isPrefixOf(testedPath)) {
219                return true;
220            }
221        }
222        return false;
223    }
224
225    @Override
226    public void trashDocuments(List<DocumentModel> docs) {
227        if (docs.isEmpty()) {
228            return;
229        }
230        CoreSession session = docs.get(0).getCoreSession();
231        for (DocumentModel doc : docs) {
232            DocumentRef docRef = doc.getRef();
233            if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.DELETE_TRANSITION)
234                    && !doc.isProxy()) {
235                if (!session.canRemoveDocument(docRef)) {
236                    throw new DocumentSecurityException("User " + session.getPrincipal().getName()
237                            + " does not have the permission to remove the document " + doc.getId() + " ("
238                            + doc.getPath() + ")");
239                }
240                trashDocument(session, doc);
241            } else if (session.getCurrentLifeCycleState(docRef).equals(LifeCycleConstants.DELETED_STATE)) {
242                log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state "
243                        + doc.getCurrentLifeCycleState() + " is already in state "
244                        + LifeCycleConstants.DELETED_STATE + ", nothing to do");
245                return;
246            } else {
247                log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state "
248                        + doc.getCurrentLifeCycleState() + " does not support transition "
249                        + LifeCycleConstants.DELETE_TRANSITION + ", it will be deleted immediately");
250                session.removeDocument(docRef);
251            }
252        }
253        session.save();
254    }
255
256    @Override
257    public void purgeDocuments(CoreSession session, List<DocumentRef> docRefs) {
258        if (docRefs.isEmpty()) {
259            return;
260        }
261        session.removeDocuments(docRefs.toArray(new DocumentRef[docRefs.size()]));
262        session.save();
263    }
264
265    @Override
266    public Set<DocumentRef> undeleteDocuments(List<DocumentModel> docs) {
267        Set<DocumentRef> undeleted = new HashSet<DocumentRef>();
268        if (docs.isEmpty()) {
269            return undeleted;
270        }
271        CoreSession session = docs.get(0).getCoreSession();
272        Set<DocumentRef> docRefs = undeleteDocumentList(session, docs);
273        undeleted.addAll(docRefs);
274        // undeleted ancestors
275        for (DocumentRef docRef : docRefs) {
276            undeleteAncestors(session, docRef, undeleted);
277        }
278        session.save();
279        // find parents of undeleted docs (for notification);
280        Set<DocumentRef> parentRefs = new HashSet<DocumentRef>();
281        for (DocumentRef docRef : undeleted) {
282            parentRefs.add(session.getParentDocumentRef(docRef));
283        }
284        // launch async action on folderish to undelete all children recursively
285        for (DocumentModel doc : docs) {
286            if (doc.isFolder()) {
287                notifyEvent(session, LifeCycleConstants.DOCUMENT_UNDELETED, doc);
288            }
289        }
290        return parentRefs;
291    }
292
293    protected void notifyEvent(CoreSession session, String eventId, DocumentModel doc) {
294        DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), doc);
295        ctx.setCategory(DocumentEventCategories.EVENT_DOCUMENT_CATEGORY);
296        ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, session.getRepositoryName());
297        ctx.setProperty(CoreEventConstants.SESSION_ID, session.getSessionId());
298        Event event = ctx.newEvent(eventId);
299        event.setInline(false);
300        event.setImmediate(true);
301        EventService eventService = Framework.getLocalService(EventService.class);
302        eventService.fireEvent(event);
303    }
304
305    /**
306     * Undeletes a list of documents. Session is not saved. Log about non-deletable documents.
307     */
308    protected Set<DocumentRef> undeleteDocumentList(CoreSession session, List<DocumentModel> docs)
309            {
310        Set<DocumentRef> undeleted = new HashSet<DocumentRef>();
311        for (DocumentModel doc : docs) {
312            DocumentRef docRef = doc.getRef();
313            if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) {
314                undeleteDocument(session, doc);
315                undeleted.add(docRef);
316            } else {
317                log.debug("Impossible to undelete document " + docRef + " as it does not support transition "
318                        + LifeCycleConstants.UNDELETE_TRANSITION);
319            }
320        }
321        return undeleted;
322    }
323
324    /**
325     * Undeletes ancestors of a document. Session is not saved. Stops as soon as an ancestor is not undeletable.
326     */
327    protected void undeleteAncestors(CoreSession session, DocumentRef docRef, Set<DocumentRef> undeleted)
328            {
329        for (DocumentRef ancestorRef : session.getParentDocumentRefs(docRef)) {
330            // getting allowed state transitions and following a transition need
331            // ReadLifeCycle and WriteLifeCycle
332            if (session.hasPermission(ancestorRef, SecurityConstants.READ_LIFE_CYCLE)
333                    && session.hasPermission(ancestorRef, SecurityConstants.WRITE_LIFE_CYCLE)) {
334                if (session.getAllowedStateTransitions(ancestorRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) {
335                    DocumentModel ancestor = session.getDocument(ancestorRef);
336                    undeleteDocument(session, ancestor);
337                    undeleted.add(ancestorRef);
338                } else {
339                    break;
340                }
341            } else {
342                // stop if lifecycle properties can't be read on an ancestor
343                log.debug("Stopping to restore ancestors because " + ancestorRef.toString() + " is not readable");
344                break;
345            }
346        }
347    }
348
349    /**
350     * Matches names of documents in the trash, created by {@link #trashDocument}.
351     */
352    protected static final Pattern TRASHED_PATTERN = Pattern.compile("(.*)\\._[0-9]{13,}_\\.trashed");
353
354    /**
355     * Matches names resulting from a collision, suffixed with a time in milliseconds, created by DuplicatedNameFixer.
356     * We also attempt to remove this when getting a doc out of the trash.
357     */
358    protected static final Pattern COLLISION_PATTERN = Pattern.compile("(.*)\\.[0-9]{13,}");
359
360    @Override
361    public String mangleName(DocumentModel doc) {
362        return doc.getName() + "._" + System.currentTimeMillis() + "_.trashed";
363    }
364
365    @Override
366    public String unmangleName(DocumentModel doc) {
367        String name = doc.getName();
368        Matcher matcher = TRASHED_PATTERN.matcher(name);
369        if (matcher.matches() && matcher.group(1).length() > 0) {
370            name = matcher.group(1);
371            matcher = COLLISION_PATTERN.matcher(name);
372            if (matcher.matches() && matcher.group(1).length() > 0) {
373                @SuppressWarnings("resource")
374                CoreSession session = doc.getCoreSession();
375                if (session != null) {
376                    String orig = matcher.group(1);
377                    String parentPath = session.getDocument(doc.getParentRef()).getPathAsString();
378                    if (parentPath.equals("/")) {
379                        parentPath = ""; // root
380                    }
381                    String newPath = parentPath + "/" + orig;
382                    if (!session.exists(new PathRef(newPath))) {
383                        name = orig;
384                    }
385                }
386            }
387        }
388        return name;
389    }
390
391    protected void trashDocument(CoreSession session, DocumentModel doc) {
392        String name = mangleName(doc);
393        session.move(doc.getRef(), doc.getParentRef(), name);
394        session.followTransition(doc, LifeCycleConstants.DELETE_TRANSITION);
395    }
396
397    protected void undeleteDocument(CoreSession session, DocumentModel doc) {
398        String name = doc.getName();
399        String newName = unmangleName(doc);
400        if (!newName.equals(name)) {
401            session.move(doc.getRef(), doc.getParentRef(), newName);
402        }
403        session.followTransition(doc, LifeCycleConstants.UNDELETE_TRANSITION);
404    }
405
406    /**
407     * {@inheritDoc}
408     */
409    @Override
410    public DocumentModelList getDocuments(DocumentModel currentDoc) {
411        CoreSession session = currentDoc.getCoreSession();
412        DocumentModelList docs = session.query(
413                String.format("SELECT * FROM " + "Document WHERE " + "ecm:mixinType != 'HiddenInNavigation' AND "
414                        + "ecm:isCheckedInVersion = 0 AND ecm:currentLifeCycleState = "
415                        + "'deleted' AND ecm:parentId = '%s'", currentDoc.getId()));
416        return docs;
417    }
418}