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