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