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