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