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