001/* 002 * Copyright (c) 2006-2011 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * All rights reserved. This program and the accompanying materials 005 * are made available under the terms of the Eclipse Public License v1.0 006 * which accompanies this distribution, and is available at 007 * http://www.eclipse.org/legal/epl-v10.html 008 * 009 * Contributors: 010 * Thierry Delprat 011 * Florent Guillaume 012 */ 013package org.nuxeo.ecm.core.trash; 014 015import java.io.Serializable; 016import java.security.Principal; 017import java.util.ArrayList; 018import java.util.Collections; 019import java.util.Comparator; 020import java.util.HashSet; 021import java.util.LinkedList; 022import java.util.List; 023import java.util.Set; 024import java.util.regex.Matcher; 025import java.util.regex.Pattern; 026 027import org.apache.commons.logging.Log; 028import org.apache.commons.logging.LogFactory; 029import org.nuxeo.common.utils.Path; 030import org.nuxeo.ecm.core.api.CoreSession; 031import org.nuxeo.ecm.core.api.DocumentModel; 032import org.nuxeo.ecm.core.api.DocumentModelList; 033import org.nuxeo.ecm.core.api.DocumentRef; 034import org.nuxeo.ecm.core.api.DocumentSecurityException; 035import org.nuxeo.ecm.core.api.LifeCycleConstants; 036import org.nuxeo.ecm.core.api.Lock; 037import org.nuxeo.ecm.core.api.NuxeoPrincipal; 038import org.nuxeo.ecm.core.api.PathRef; 039import org.nuxeo.ecm.core.api.event.CoreEventConstants; 040import org.nuxeo.ecm.core.api.event.DocumentEventCategories; 041import org.nuxeo.ecm.core.api.security.SecurityConstants; 042import org.nuxeo.ecm.core.event.Event; 043import org.nuxeo.ecm.core.event.EventService; 044import org.nuxeo.ecm.core.event.impl.DocumentEventContext; 045import org.nuxeo.runtime.api.Framework; 046import org.nuxeo.runtime.model.DefaultComponent; 047 048public class TrashServiceImpl extends DefaultComponent implements TrashService { 049 050 private static final Log log = LogFactory.getLog(TrashServiceImpl.class); 051 052 @Override 053 public boolean folderAllowsDelete(DocumentModel folder) { 054 return folder.getCoreSession().hasPermission(folder.getRef(), SecurityConstants.REMOVE_CHILDREN); 055 } 056 057 @Override 058 public boolean checkDeletePermOnParents(List<DocumentModel> docs) { 059 if (docs.isEmpty()) { 060 return false; 061 } 062 CoreSession session = docs.get(0).getCoreSession(); 063 for (DocumentModel doc : docs) { 064 if (session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) { 065 return true; 066 } 067 } 068 return false; 069 } 070 071 @Override 072 public boolean canDelete(List<DocumentModel> docs, Principal principal, boolean checkProxies) 073 { 074 if (docs.isEmpty()) { 075 return false; 076 } 077 // used to do only check on parent perm 078 TrashInfo info = getInfo(docs, principal, checkProxies, false); 079 return info.docs.size() > 0; 080 } 081 082 @Override 083 public boolean canPurgeOrUndelete(List<DocumentModel> docs, Principal principal) { 084 if (docs.isEmpty()) { 085 return false; 086 } 087 // used to do only check on parent perm 088 TrashInfo info = getInfo(docs, principal, false, true); 089 return info.docs.size() == docs.size(); 090 } 091 092 public boolean canUndelete(List<DocumentModel> docs) { 093 if (docs.isEmpty()) { 094 return false; 095 } 096 // used to do only check on parent perm 097 TrashInfo info = getInfo(docs, null, false, true); 098 return info.docs.size() > 0; 099 } 100 101 protected TrashInfo getInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies, 102 boolean checkDeleted) { 103 TrashInfo info = new TrashInfo(); 104 info.docs = new ArrayList<DocumentModel>(docs.size()); 105 if (docs.isEmpty()) { 106 return info; 107 } 108 CoreSession session = docs.get(0).getCoreSession(); 109 for (DocumentModel doc : docs) { 110 if (checkDeleted && !LifeCycleConstants.DELETED_STATE.equals(doc.getCurrentLifeCycleState())) { 111 info.forbidden++; 112 continue; 113 } 114 if (doc.getParentRef() == null) { 115 if (doc.isVersion() && !session.getProxies(doc.getRef(), null).isEmpty()) { 116 // do not remove versions used by proxies 117 info.forbidden++; 118 continue; 119 } 120 121 } else { 122 if (!session.hasPermission(doc.getParentRef(), SecurityConstants.REMOVE_CHILDREN)) { 123 info.forbidden++; 124 continue; 125 } 126 } 127 if (!session.hasPermission(doc.getRef(), SecurityConstants.REMOVE)) { 128 info.forbidden++; 129 continue; 130 } 131 if (checkProxies && doc.isProxy()) { 132 info.proxies++; 133 continue; 134 } 135 if (doc.isLocked()) { 136 String locker = getDocumentLocker(doc); 137 if (principal == null 138 || (principal instanceof NuxeoPrincipal && ((NuxeoPrincipal) principal).isAdministrator()) 139 || principal.getName().equals(locker)) { 140 info.docs.add(doc); 141 } else { 142 info.locked++; 143 } 144 } else { 145 info.docs.add(doc); 146 } 147 } 148 return info; 149 } 150 151 protected static String getDocumentLocker(DocumentModel doc) { 152 Lock lock = doc.getLockInfo(); 153 return lock == null ? null : lock.getOwner(); 154 } 155 156 /** 157 * Path-based comparator used to put folders before their children. 158 */ 159 protected static class PathComparator implements Comparator<DocumentModel>, Serializable { 160 161 private static final long serialVersionUID = 1L; 162 163 public static PathComparator INSTANCE = new PathComparator(); 164 165 @Override 166 public int compare(DocumentModel doc1, DocumentModel doc2) { 167 return doc1.getPathAsString().replace("/", "\u0000").compareTo( 168 doc2.getPathAsString().replace("/", "\u0000")); 169 } 170 171 } 172 173 @Override 174 public TrashInfo getTrashInfo(List<DocumentModel> docs, Principal principal, boolean checkProxies, 175 boolean checkDeleted) { 176 TrashInfo info = getInfo(docs, principal, checkProxies, checkDeleted); 177 // Keep only common tree roots (see NXP-1411) 178 // This is not strictly necessary with Nuxeo Core >= 1.3.2 179 Collections.sort(info.docs, PathComparator.INSTANCE); 180 List<DocumentModel> roots = new LinkedList<DocumentModel>(); 181 info.rootPaths = new HashSet<Path>(); 182 info.rootRefs = new LinkedList<DocumentRef>(); 183 info.rootParentRefs = new HashSet<DocumentRef>(); 184 Path previousPath = null; 185 for (DocumentModel doc : info.docs) { 186 if (previousPath == null || !previousPath.isPrefixOf(doc.getPath())) { 187 roots.add(doc); 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 } 206 return doc; 207 } 208 209 protected static boolean underOneOf(Path testedPath, Set<Path> paths) { 210 for (Path path : paths) { 211 if (path != null && path.isPrefixOf(testedPath)) { 212 return true; 213 } 214 } 215 return false; 216 } 217 218 @Override 219 public void trashDocuments(List<DocumentModel> docs) { 220 if (docs.isEmpty()) { 221 return; 222 } 223 CoreSession session = docs.get(0).getCoreSession(); 224 for (DocumentModel doc : docs) { 225 DocumentRef docRef = doc.getRef(); 226 if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.DELETE_TRANSITION) 227 && !doc.isProxy()) { 228 if (!session.canRemoveDocument(docRef)) { 229 throw new DocumentSecurityException("User " + session.getPrincipal().getName() 230 + " does not have the permission to remove the document " + doc.getId() + " (" 231 + doc.getPath() + ")"); 232 } 233 trashDocument(session, doc); 234 } else if (session.getCurrentLifeCycleState(docRef).equals(LifeCycleConstants.DELETED_STATE)) { 235 log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state " 236 + doc.getCurrentLifeCycleState() + " is already in state " 237 + LifeCycleConstants.DELETED_STATE + ", nothing to do"); 238 return; 239 } else { 240 log.warn("Document " + doc.getId() + " of type " + doc.getType() + " in state " 241 + doc.getCurrentLifeCycleState() + " does not support transition " 242 + LifeCycleConstants.DELETE_TRANSITION + ", it will be deleted immediately"); 243 session.removeDocument(docRef); 244 } 245 } 246 session.save(); 247 } 248 249 @Override 250 public void purgeDocuments(CoreSession session, List<DocumentRef> docRefs) { 251 if (docRefs.isEmpty()) { 252 return; 253 } 254 session.removeDocuments(docRefs.toArray(new DocumentRef[docRefs.size()])); 255 session.save(); 256 } 257 258 @Override 259 public Set<DocumentRef> undeleteDocuments(List<DocumentModel> docs) { 260 Set<DocumentRef> undeleted = new HashSet<DocumentRef>(); 261 if (docs.isEmpty()) { 262 return undeleted; 263 } 264 CoreSession session = docs.get(0).getCoreSession(); 265 Set<DocumentRef> docRefs = undeleteDocumentList(session, docs); 266 undeleted.addAll(docRefs); 267 // undeleted ancestors 268 for (DocumentRef docRef : docRefs) { 269 undeleteAncestors(session, docRef, undeleted); 270 } 271 session.save(); 272 // find parents of undeleted docs (for notification); 273 Set<DocumentRef> parentRefs = new HashSet<DocumentRef>(); 274 for (DocumentRef docRef : undeleted) { 275 parentRefs.add(session.getParentDocumentRef(docRef)); 276 } 277 // launch async action on folderish to undelete all children recursively 278 for (DocumentModel doc : docs) { 279 if (doc.isFolder()) { 280 notifyEvent(session, LifeCycleConstants.DOCUMENT_UNDELETED, doc); 281 } 282 } 283 return parentRefs; 284 } 285 286 protected void notifyEvent(CoreSession session, String eventId, DocumentModel doc) { 287 DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), doc); 288 ctx.setCategory(DocumentEventCategories.EVENT_DOCUMENT_CATEGORY); 289 ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, session.getRepositoryName()); 290 ctx.setProperty(CoreEventConstants.SESSION_ID, session.getSessionId()); 291 Event event = ctx.newEvent(eventId); 292 event.setInline(false); 293 event.setImmediate(true); 294 EventService eventService = Framework.getLocalService(EventService.class); 295 eventService.fireEvent(event); 296 } 297 298 /** 299 * Undeletes a list of documents. Session is not saved. Log about non-deletable documents. 300 */ 301 protected Set<DocumentRef> undeleteDocumentList(CoreSession session, List<DocumentModel> docs) 302 { 303 Set<DocumentRef> undeleted = new HashSet<DocumentRef>(); 304 for (DocumentModel doc : docs) { 305 DocumentRef docRef = doc.getRef(); 306 if (session.getAllowedStateTransitions(docRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) { 307 undeleteDocument(session, doc); 308 undeleted.add(docRef); 309 } else { 310 log.debug("Impossible to undelete document " + docRef + " as it does not support transition " 311 + LifeCycleConstants.UNDELETE_TRANSITION); 312 } 313 } 314 return undeleted; 315 } 316 317 /** 318 * Undeletes ancestors of a document. Session is not saved. Stops as soon as an ancestor is not undeletable. 319 */ 320 protected void undeleteAncestors(CoreSession session, DocumentRef docRef, Set<DocumentRef> undeleted) 321 { 322 for (DocumentRef ancestorRef : session.getParentDocumentRefs(docRef)) { 323 // getting allowed state transitions and following a transition need 324 // ReadLifeCycle and WriteLifeCycle 325 if (session.hasPermission(ancestorRef, SecurityConstants.READ_LIFE_CYCLE) 326 && session.hasPermission(ancestorRef, SecurityConstants.WRITE_LIFE_CYCLE)) { 327 if (session.getAllowedStateTransitions(ancestorRef).contains(LifeCycleConstants.UNDELETE_TRANSITION)) { 328 DocumentModel ancestor = session.getDocument(ancestorRef); 329 undeleteDocument(session, ancestor); 330 undeleted.add(ancestorRef); 331 } else { 332 break; 333 } 334 } else { 335 // stop if lifecycle properties can't be read on an ancestor 336 log.debug("Stopping to restore ancestors because " + ancestorRef.toString() + " is not readable"); 337 break; 338 } 339 } 340 } 341 342 /** 343 * Matches names of documents in the trash, created by {@link #trashDocument}. 344 */ 345 protected static final Pattern TRASHED_PATTERN = Pattern.compile("(.*)\\._[0-9]{13,}_\\.trashed"); 346 347 /** 348 * Matches names resulting from a collision, suffixed with a time in milliseconds, created by DuplicatedNameFixer. 349 * We also attempt to remove this when getting a doc out of the trash. 350 */ 351 protected static final Pattern COLLISION_PATTERN = Pattern.compile("(.*)\\.[0-9]{13,}"); 352 353 @Override 354 public String mangleName(DocumentModel doc) { 355 return doc.getName() + "._" + System.currentTimeMillis() + "_.trashed"; 356 } 357 358 @Override 359 public String unmangleName(DocumentModel doc) { 360 String name = doc.getName(); 361 Matcher matcher = TRASHED_PATTERN.matcher(name); 362 if (matcher.matches() && matcher.group(1).length() > 0) { 363 name = matcher.group(1); 364 matcher = COLLISION_PATTERN.matcher(name); 365 if (matcher.matches() && matcher.group(1).length() > 0) { 366 @SuppressWarnings("resource") 367 CoreSession session = doc.getCoreSession(); 368 if (session != null) { 369 String orig = matcher.group(1); 370 String parentPath = session.getDocument(doc.getParentRef()).getPathAsString(); 371 if (parentPath.equals("/")) { 372 parentPath = ""; // root 373 } 374 String newPath = parentPath + "/" + orig; 375 if (!session.exists(new PathRef(newPath))) { 376 name = orig; 377 } 378 } 379 } 380 } 381 return name; 382 } 383 384 protected void trashDocument(CoreSession session, DocumentModel doc) { 385 String name = mangleName(doc); 386 session.move(doc.getRef(), doc.getParentRef(), name); 387 session.followTransition(doc, LifeCycleConstants.DELETE_TRANSITION); 388 } 389 390 protected void undeleteDocument(CoreSession session, DocumentModel doc) { 391 String name = doc.getName(); 392 String newName = unmangleName(doc); 393 if (!newName.equals(name)) { 394 session.move(doc.getRef(), doc.getParentRef(), newName); 395 } 396 session.followTransition(doc, LifeCycleConstants.UNDELETE_TRANSITION); 397 } 398 399 /** 400 * {@inheritDoc} 401 */ 402 @Override 403 public DocumentModelList getDocuments(DocumentModel currentDoc) { 404 CoreSession session = currentDoc.getCoreSession(); 405 DocumentModelList docs = session.query( 406 String.format("SELECT * FROM " + "Document WHERE " + "ecm:mixinType != 'HiddenInNavigation' AND " 407 + "ecm:isCheckedInVersion = 0 AND ecm:currentLifeCycleState = " 408 + "'deleted' AND ecm:parentId = '%s'", currentDoc.getId())); 409 return docs; 410 } 411}