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}