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