001/*
002 * (C) Copyright 2018 Nuxeo (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 *     Kevin Leturc <kleturc@nuxeo.com>
018 */
019package org.nuxeo.ecm.core.trash;
020
021import java.io.Serializable;
022import java.security.Principal;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.Comparator;
026import java.util.HashMap;
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;
033import java.util.stream.StreamSupport;
034
035import org.nuxeo.common.utils.Path;
036import org.nuxeo.ecm.core.api.CoreSession;
037import org.nuxeo.ecm.core.api.DocumentModel;
038import org.nuxeo.ecm.core.api.DocumentModelList;
039import org.nuxeo.ecm.core.api.DocumentRef;
040import org.nuxeo.ecm.core.api.IdRef;
041import org.nuxeo.ecm.core.api.IterableQueryResult;
042import org.nuxeo.ecm.core.api.Lock;
043import org.nuxeo.ecm.core.api.NuxeoPrincipal;
044import org.nuxeo.ecm.core.api.PathRef;
045import org.nuxeo.ecm.core.api.event.CoreEventConstants;
046import org.nuxeo.ecm.core.api.event.DocumentEventCategories;
047import org.nuxeo.ecm.core.api.security.SecurityConstants;
048import org.nuxeo.ecm.core.event.Event;
049import org.nuxeo.ecm.core.event.EventService;
050import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
051import org.nuxeo.ecm.core.query.sql.NXQL;
052import org.nuxeo.ecm.core.schema.FacetNames;
053import org.nuxeo.runtime.api.Framework;
054
055/**
056 * Basic implementation of {@link TrashService}.
057 *
058 * @since 10.1
059 */
060public abstract class AbstractTrashService implements TrashService {
061
062    public static final String TRASHED_QUERY = "SELECT * FROM Document WHERE ecm:mixinType != 'HiddenInNavigation' AND ecm:isVersion = 0 AND ecm:isTrashed = 1 AND ecm:parentId = '%s'";
063
064    @Override
065    public boolean folderAllowsDelete(DocumentModel folder) {
066        return folder.getCoreSession().hasPermission(folder.getRef(), SecurityConstants.REMOVE_CHILDREN);
067    }
068
069    @Override
070    public boolean checkDeletePermOnParents(List<DocumentModel> docs) {
071        if (docs.isEmpty()) {
072            return false;
073        }
074        CoreSession session = docs.get(0).getCoreSession();
075        for (DocumentModel doc : docs) {
076            if (session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) {
077                return true;
078            }
079        }
080        return false;
081    }
082
083    @Override
084    public boolean canDelete(List<DocumentModel> docs, Principal principal, boolean checkProxies) {
085        if (docs.isEmpty()) {
086            return false;
087        }
088        // used to do only check on parent perm
089        TrashInfo info = getInfo(docs, principal, checkProxies, false);
090        return info.docs.size() > 0;
091    }
092
093    @Override
094    public boolean canPurgeOrUntrash(List<DocumentModel> docs, Principal principal) {
095        if (docs.isEmpty()) {
096            return false;
097        }
098        // used to do only check on parent perm
099        TrashInfo info = getInfo(docs, principal, false, true);
100        return info.docs.size() == docs.size();
101    }
102
103    protected TrashInfo getInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies,
104            boolean checkDeleted) {
105        TrashInfo info = new TrashInfo();
106        info.docs = new ArrayList<>(docs.size());
107        if (docs.isEmpty()) {
108            return info;
109        }
110        CoreSession session = docs.get(0).getCoreSession();
111        for (DocumentModel doc : docs) {
112            if (checkDeleted && !doc.isTrashed()) {
113                info.forbidden++;
114                continue;
115            }
116            if (doc.getParentRef() == null) {
117                if (doc.isVersion() && !session.getProxies(doc.getRef(), null).isEmpty()) {
118                    // do not remove versions used by proxies
119                    info.forbidden++;
120                    continue;
121                }
122
123            } else {
124                if (!session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) {
125                    info.forbidden++;
126                    continue;
127                }
128            }
129            if (!session.hasPermission(doc.getRef(), SecurityConstants.REMOVE)) {
130                info.forbidden++;
131                continue;
132            }
133            if (checkProxies && doc.isProxy()) {
134                info.proxies++;
135                continue;
136            }
137            if (doc.isLocked()) {
138                String locker = getDocumentLocker(doc);
139                if (principal == null
140                        || (principal instanceof NuxeoPrincipal && ((NuxeoPrincipal) principal).isAdministrator())
141                        || principal.getName().equals(locker)) {
142                    info.docs.add(doc);
143                } else {
144                    info.locked++;
145                }
146            } else {
147                info.docs.add(doc);
148            }
149        }
150        return info;
151    }
152
153    protected static String getDocumentLocker(DocumentModel doc) {
154        Lock lock = doc.getLockInfo();
155        return lock == null ? null : lock.getOwner();
156    }
157
158    /**
159     * Path-based comparator used to put folders before their children.
160     */
161    protected static class PathComparator implements Comparator<DocumentModel>, Serializable {
162
163        private static final long serialVersionUID = 1L;
164
165        public static final PathComparator INSTANCE = new PathComparator();
166
167        @Override
168        public int compare(DocumentModel doc1, DocumentModel doc2) {
169            return doc1.getPathAsString().replace("/", "\u0000").compareTo(
170                    doc2.getPathAsString().replace("/", "\u0000"));
171        }
172
173    }
174
175    @Override
176    public TrashInfo getTrashInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies,
177            boolean checkDeleted) {
178        TrashInfo info = getInfo(docs, principal, checkProxies, checkDeleted);
179        // Keep only common tree roots (see NXP-1411)
180        // This is not strictly necessary with Nuxeo Core >= 1.3.2
181        info.docs.sort(PathComparator.INSTANCE);
182        info.rootPaths = new HashSet<>();
183        info.rootRefs = new LinkedList<>();
184        info.rootParentRefs = new HashSet<>();
185        Path previousPath = null;
186        for (DocumentModel doc : info.docs) {
187            if (previousPath == null || !previousPath.isPrefixOf(doc.getPath())) {
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            if (doc == null) {
206                // handle placeless document
207                break;
208            }
209        }
210        return doc;
211    }
212
213    @Override
214    public DocumentModel getAboveDocument(DocumentModel doc, Principal principal) {
215        TrashInfo info = getTrashInfo(Collections.singletonList(doc), principal, false, false);
216        return getAboveDocument(doc, info.rootPaths);
217    }
218
219    protected static boolean underOneOf(Path testedPath, Set<Path> paths) {
220        for (Path path : paths) {
221            if (path != null && path.isPrefixOf(testedPath)) {
222                return true;
223            }
224        }
225        return false;
226    }
227
228    @Override
229    public void purgeDocuments(CoreSession session, List<DocumentRef> docRefs) {
230        if (docRefs.isEmpty()) {
231            return;
232        }
233        session.removeDocuments(docRefs.toArray(new DocumentRef[docRefs.size()]));
234        session.save();
235    }
236
237    @Override
238    public void purgeDocumentsUnder(DocumentModel parent) {
239        if (parent == null || !parent.hasFacet(FacetNames.FOLDERISH)) {
240            throw new UnsupportedOperationException("Empty trash can only be performed on a Folderish document");
241        }
242        CoreSession session = parent.getCoreSession();
243        if (!session.hasPermission(parent.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) {
244            return;
245        }
246        try (IterableQueryResult result = session.queryAndFetch(String.format(TRASHED_QUERY, parent.getId()),
247                NXQL.NXQL)) {
248            NuxeoPrincipal principal = (NuxeoPrincipal) session.getPrincipal();
249            StreamSupport.stream(result.spliterator(), false)
250                         .map(map -> map.get(NXQL.ECM_UUID).toString())
251                         .map(IdRef::new)
252                         // check user has permission to remove document
253                         .filter(ref -> session.hasPermission(ref, SecurityConstants.REMOVE))
254                         // check user has permission to remove a locked document
255                         .filter(ref -> {
256                             if (principal == null || principal.isAdministrator()) {
257                                 // administrator can remove anything
258                                 return true;
259                             } else {
260                                 // only lock owner can remove locked document
261                                 DocumentModel doc = session.getDocument(ref);
262                                 return !doc.isLocked() || principal.getName().equals(getDocumentLocker(doc));
263                             }
264                         })
265                         .forEach(session::removeDocument);
266        }
267        session.save();
268    }
269
270    protected void notifyEvent(CoreSession session, String eventId, DocumentModel doc) {
271        notifyEvent(session, eventId, doc, false);
272    }
273
274    protected void notifyEvent(CoreSession session, String eventId, DocumentModel doc, boolean immediate) {
275        DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), doc);
276        ctx.setProperties(new HashMap<>(doc.getContextData()));
277        ctx.setCategory(DocumentEventCategories.EVENT_DOCUMENT_CATEGORY);
278        ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, session.getRepositoryName());
279        ctx.setProperty(CoreEventConstants.SESSION_ID, session.getSessionId());
280        Event event = ctx.newEvent(eventId);
281        event.setInline(false);
282        event.setImmediate(immediate);
283        EventService eventService = Framework.getService(EventService.class);
284        eventService.fireEvent(event);
285    }
286
287    @Override
288    public DocumentModelList getDocuments(DocumentModel parent) {
289        CoreSession session = parent.getCoreSession();
290        return session.query(String.format(TRASHED_QUERY, parent.getId()));
291    }
292
293    @Override
294    public void untrashDocuments(List<DocumentModel> docs) {
295        undeleteDocuments(docs);
296    }
297
298    /**
299     * Matches names of documents in the trash, created by {@link #trashDocuments(List)}.
300     */
301    protected static final Pattern TRASHED_PATTERN = Pattern.compile("(.*)\\._[0-9]{13,}_\\.trashed");
302
303    /**
304     * Matches names resulting from a collision, suffixed with a time in milliseconds, created by DuplicatedNameFixer.
305     * We also attempt to remove this when getting a doc out of the trash.
306     */
307    protected static final Pattern COLLISION_PATTERN = Pattern.compile("(.*)\\.[0-9]{13,}");
308
309    @Override
310    public String mangleName(DocumentModel doc) {
311        return doc.getName() + "._" + System.currentTimeMillis() + "_.trashed";
312    }
313
314    @Override
315    public String unmangleName(DocumentModel doc) {
316        String name = doc.getName();
317        Matcher matcher = TRASHED_PATTERN.matcher(name);
318        if (matcher.matches() && matcher.group(1).length() > 0) {
319            name = matcher.group(1);
320            matcher = COLLISION_PATTERN.matcher(name);
321            if (matcher.matches() && matcher.group(1).length() > 0) {
322                CoreSession session = doc.getCoreSession();
323                if (session != null) {
324                    String orig = matcher.group(1);
325                    String parentPath = session.getDocument(doc.getParentRef()).getPathAsString();
326                    if (parentPath.equals("/")) {
327                        parentPath = ""; // root
328                    }
329                    String newPath = parentPath + "/" + orig;
330                    if (!session.exists(new PathRef(newPath))) {
331                        name = orig;
332                    }
333                }
334            }
335        }
336        return name;
337    }
338
339}