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