001/* 002 * (C) Copyright 2006-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 * Nuxeo - initial API and implementation 018 * 019 * $Id$ 020 */ 021package org.nuxeo.ecm.platform.userworkspace.core.service; 022 023import java.io.Serializable; 024import java.security.MessageDigest; 025import java.security.NoSuchAlgorithmException; 026import java.util.ArrayList; 027import java.util.HashMap; 028import java.util.List; 029import java.util.Locale; 030import java.util.Map; 031import java.util.MissingResourceException; 032 033import org.apache.commons.codec.binary.Hex; 034import org.apache.commons.lang3.StringUtils; 035import org.nuxeo.common.utils.IdUtils; 036import org.nuxeo.common.utils.Path; 037import org.nuxeo.common.utils.i18n.I18NUtils; 038import org.nuxeo.ecm.collections.api.CollectionConstants; 039import org.nuxeo.ecm.collections.api.CollectionLocationService; 040import org.nuxeo.ecm.collections.api.FavoritesConstants; 041import org.nuxeo.ecm.core.api.CoreInstance; 042import org.nuxeo.ecm.core.api.CoreSession; 043import org.nuxeo.ecm.core.api.DocumentModel; 044import org.nuxeo.ecm.core.api.DocumentModelList; 045import org.nuxeo.ecm.core.api.DocumentSecurityException; 046import org.nuxeo.ecm.core.api.NuxeoException; 047import org.nuxeo.ecm.core.api.NuxeoPrincipal; 048import org.nuxeo.ecm.core.api.PathRef; 049import org.nuxeo.ecm.core.api.UnrestrictedSessionRunner; 050import org.nuxeo.ecm.core.api.event.CoreEventConstants; 051import org.nuxeo.ecm.core.api.pathsegment.PathSegmentService; 052import org.nuxeo.ecm.core.api.security.ACE; 053import org.nuxeo.ecm.core.api.security.ACL; 054import org.nuxeo.ecm.core.api.security.ACP; 055import org.nuxeo.ecm.core.api.security.Access; 056import org.nuxeo.ecm.core.api.security.SecurityConstants; 057import org.nuxeo.ecm.core.api.security.impl.ACLImpl; 058import org.nuxeo.ecm.core.api.security.impl.ACPImpl; 059import org.nuxeo.ecm.core.event.Event; 060import org.nuxeo.ecm.core.event.EventContext; 061import org.nuxeo.ecm.core.event.EventProducer; 062import org.nuxeo.ecm.core.event.impl.DocumentEventContext; 063import org.nuxeo.ecm.core.event.impl.EventContextImpl; 064import org.nuxeo.ecm.platform.usermanager.UserAdapter; 065import org.nuxeo.ecm.platform.usermanager.UserManager; 066import org.nuxeo.ecm.platform.userworkspace.api.UserWorkspaceService; 067import org.nuxeo.ecm.platform.userworkspace.constants.UserWorkspaceConstants; 068import org.nuxeo.ecm.platform.web.common.locale.LocaleProvider; 069import org.nuxeo.runtime.api.Framework; 070 071/** 072 * Abstract class holding most of the logic for using {@link UnrestrictedSessionRunner} while creating UserWorkspaces 073 * and associated resources 074 * 075 * @author tiry 076 * @since 5.9.5 077 */ 078public abstract class AbstractUserWorkspaceImpl implements UserWorkspaceService, CollectionLocationService { 079 080 private static final long serialVersionUID = 1L; 081 082 protected static final char ESCAPE_CHAR = '~'; 083 084 protected static final String ESCAPED_CHARS = ESCAPE_CHAR + "/\\?&;@"; 085 086 protected volatile String targetDomainName; 087 088 protected final int maxsize; 089 090 public AbstractUserWorkspaceImpl() { 091 super(); 092 maxsize = Framework.getService(PathSegmentService.class).getMaxSize(); 093 } 094 095 protected String getDomainName(CoreSession userCoreSession) { 096 if (targetDomainName == null) { 097 CoreInstance.doPrivileged(userCoreSession, (CoreSession session) -> { 098 String targetName = getComponent().getTargetDomainName(); 099 PathRef ref = new PathRef("/" + targetName); 100 if (session.exists(ref)) { 101 targetDomainName = targetName; 102 return; 103 } 104 // configured domain does not exist !!! 105 DocumentModelList domains = session.query( 106 "select * from Domain where ecm:isTrashed = 0 order by dc:created"); 107 108 if (!domains.isEmpty()) { 109 targetDomainName = domains.get(0).getName(); 110 } 111 }); 112 } 113 return targetDomainName; 114 } 115 116 /** 117 * Gets the base username to use to determine a user's workspace. This is not used directly as a path segment, but 118 * forms the sole basis for it. 119 * 120 * @since 9.2 121 */ 122 protected String getUserName(NuxeoPrincipal principal, String username) { 123 if (principal != null) { 124 username = principal.getActingUser(); 125 } 126 if (NuxeoPrincipal.isTransientUsername(username)) { 127 // no personal workspace for transient users 128 username = null; 129 } 130 if (StringUtils.isEmpty(username)) { 131 username = null; 132 } 133 return username; 134 } 135 136 /** 137 * Finds the list of potential names for the user workspace. They're all tried in order. 138 * 139 * @return the list of candidate names 140 * @since 9.2 141 */ 142 // public for tests 143 public List<String> getCandidateUserWorkspaceNames(String username) { 144 List<String> names = new ArrayList<>(); 145 names.add(escape(username)); 146 generateCandidates(names, username, maxsize); // compat 147 generateCandidates(names, username, 30); // old compat 148 return names; 149 } 150 151 /** 152 * Bijective escaping for user names. 153 * <p> 154 * Escapes some chars not allowed in a path segment or URL. The escaping character is a {@code ~} followed by the 155 * one-byte hex value of the character. 156 * 157 * @since 9.2 158 */ 159 protected String escape(String string) { 160 StringBuilder sb = new StringBuilder(string.length()); 161 for (char c : string.toCharArray()) { 162 if (ESCAPED_CHARS.indexOf(c) == -1) { 163 sb.append(c); 164 } else { 165 sb.append(ESCAPE_CHAR); 166 if (c < 16) { 167 sb.append('0'); 168 } 169 sb.append(Integer.toHexString(c)); // assumed to be < 256 170 } 171 } 172 // don't re-allocate a new string if we didn't escape anything 173 return sb.length() == string.length() ? string : sb.toString(); 174 } 175 176 protected void generateCandidates(List<String> names, String username, int max) { 177 String name = IdUtils.generateId(username, "-", false, max); 178 if (!names.contains(name)) { 179 names.add(name); 180 } 181 if (name.length() == max) { // at max size or truncated 182 String digested = name.substring(0, name.length() - 8) + digest(username, 8); 183 if (!names.contains(digested)) { 184 names.add(digested); 185 } 186 } 187 } 188 189 protected String digest(String username, int maxsize) { 190 try { 191 MessageDigest crypt = MessageDigest.getInstance("SHA-1"); 192 crypt.update(username.getBytes()); 193 return new String(Hex.encodeHex(crypt.digest())).substring(0, maxsize); 194 } catch (NoSuchAlgorithmException cause) { 195 throw new NuxeoException("Cannot digest " + username, cause); 196 } 197 } 198 199 protected String computePathUserWorkspaceRoot(CoreSession userCoreSession, String usedUsername) { 200 String domainName = getDomainName(userCoreSession); 201 if (domainName == null) { 202 return null; 203 } 204 return new Path("/" + domainName).append(UserWorkspaceConstants.USERS_PERSONAL_WORKSPACES_ROOT).toString(); 205 } 206 207 @Override 208 public DocumentModel getCurrentUserPersonalWorkspace(String userName, DocumentModel currentDocument) { 209 return getCurrentUserPersonalWorkspace(null, userName, currentDocument.getCoreSession()); 210 } 211 212 @Override 213 public DocumentModel getCurrentUserPersonalWorkspace(CoreSession userCoreSession) { 214 return getCurrentUserPersonalWorkspace(userCoreSession.getPrincipal(), null, userCoreSession); 215 } 216 217 /** 218 * Only for compatibility. 219 * 220 * @deprecated since 9.3 221 */ 222 @Deprecated 223 @Override 224 public DocumentModel getCurrentUserPersonalWorkspace(CoreSession userCoreSession, DocumentModel context) { 225 return getCurrentUserPersonalWorkspace(userCoreSession); 226 } 227 228 /** 229 * This method handles the UserWorkspace creation with a Principal or a username. At least one should be passed. If 230 * a principal is passed, the username is not taken into account. 231 * 232 * @since 5.7 "userWorkspaceCreated" is triggered 233 */ 234 protected DocumentModel getCurrentUserPersonalWorkspace(NuxeoPrincipal principal, String userName, 235 CoreSession userCoreSession) { 236 String usedUsername = getUserName(principal, userName); 237 if (usedUsername == null) { 238 return null; 239 } 240 PathRef rootref = getExistingUserWorkspaceRoot(userCoreSession, usedUsername); 241 if (rootref == null) { 242 return null; 243 } 244 PathRef uwref = getExistingUserWorkspace(userCoreSession, rootref, principal, usedUsername); 245 return userCoreSession.getDocument(uwref); 246 } 247 248 protected PathRef getExistingUserWorkspaceRoot(CoreSession session, String username) { 249 String uwrPath = computePathUserWorkspaceRoot(session, username); 250 if (uwrPath == null) { 251 return null; 252 } 253 PathRef rootref = new PathRef(uwrPath); 254 if (session.exists(rootref)) { 255 return rootref; 256 } 257 258 String path = CoreInstance.doPrivileged(session, s -> { 259 DocumentModel uwsRootModel = doCreateUserWorkspacesRoot(session, rootref); 260 DocumentModel docModel = s.getOrCreateDocument(uwsRootModel, doc -> initCreateUserWorkspacesRoot(s, doc)); 261 return docModel.getPathAsString(); 262 }); 263 264 return new PathRef(path); 265 } 266 267 protected PathRef getExistingUserWorkspace(CoreSession session, PathRef rootref, NuxeoPrincipal principal, 268 String username) { 269 PathRef freeRef = null; 270 for (String name : getCandidateUserWorkspaceNames(username)) { 271 PathRef ref = new PathRef(rootref, name); 272 if (session.exists(ref) 273 && session.hasPermission(ref, SecurityConstants.EVERYTHING)) { 274 // make sure it's the username's user workspace 275 DocumentModel uw = session.getDocument(ref); 276 if (Access.GRANT == uw.getACP().getAccess(username, SecurityConstants.EVERYTHING)) { 277 return ref; 278 } 279 } 280 @SuppressWarnings("boxing") 281 boolean exists = CoreInstance.doPrivileged(session, (CoreSession s) -> s.exists(ref)); 282 if (!exists && freeRef == null) { 283 // we have a candidate name for creation if we don't find anything else 284 freeRef = ref; 285 } 286 // else if exists it means there's a collision with the truncated workspace of another user 287 // try next name 288 } 289 if (freeRef != null) { 290 PathRef ref = freeRef; // effectively final 291 String path = CoreInstance.doPrivileged(session, s -> { 292 DocumentModel uwsModel = doCreateUserWorkspace(session, ref, username); 293 return s.getOrCreateDocument(uwsModel, doc -> initCreateUserWorkspace(s, doc, username)) 294 .getPathAsString(); 295 }); 296 return new PathRef(path); 297 } 298 // couldn't find anything, because we lacked permission to existing docs (collision) 299 throw new DocumentSecurityException(username); 300 } 301 302 @Override 303 public DocumentModel getUserPersonalWorkspace(NuxeoPrincipal principal, DocumentModel context) { 304 return getCurrentUserPersonalWorkspace(principal, null, context.getCoreSession()); 305 } 306 307 @Override 308 public DocumentModel getUserPersonalWorkspace(String userName, DocumentModel context) { 309 UnrestrictedUserWorkspaceFinder finder = new UnrestrictedUserWorkspaceFinder(userName, context); 310 finder.runUnrestricted(); 311 return finder.getDetachedUserWorkspace(); 312 } 313 314 @Override 315 public boolean isUnderUserWorkspace(NuxeoPrincipal principal, String username, DocumentModel doc) { 316 if (doc == null) { 317 return false; 318 } 319 username = getUserName(principal, username); 320 if (username == null) { 321 return false; 322 } 323 324 // fast checks that are useful to return a negative without the cost of accessing the user workspace 325 Path path = doc.getPath(); 326 if (path.segmentCount() < 2) { 327 return false; 328 } 329 // check domain 330 String domainName = getDomainName(doc.getCoreSession()); 331 if (!domainName.equals(path.segment(0))) { 332 return false; 333 } 334 // check UWS root 335 if (!UserWorkspaceConstants.USERS_PERSONAL_WORKSPACES_ROOT.equals(path.segment(1))) { 336 return false; 337 } 338 // check workspace name among candidates 339 if (!getCandidateUserWorkspaceNames(username).contains(path.segment(2))) { 340 return false; 341 } 342 343 // fetch actual workspace to compare its path 344 DocumentModel uws = getCurrentUserPersonalWorkspace(principal, username, doc.getCoreSession()); 345 return uws.getPath().isPrefixOf(doc.getPath()); 346 } 347 348 protected String buildUserWorkspaceTitle(String userName) { 349 if (userName == null) {// avoid looking for UserManager for nothing 350 return null; 351 } 352 // get the user service 353 UserManager userManager = Framework.getService(UserManager.class); 354 if (userManager == null) { 355 // for tests 356 return userName; 357 } 358 359 // Adapter userModel to get its fields (firstname, lastname) 360 DocumentModel userModel = userManager.getUserModel(userName); 361 if (userModel == null) { 362 return userName; 363 } 364 365 UserAdapter userAdapter; 366 userAdapter = userModel.getAdapter(UserAdapter.class); 367 368 if (userAdapter == null) { 369 return userName; 370 } 371 372 // compute the title 373 StringBuilder title = new StringBuilder(); 374 String firstName = userAdapter.getFirstName(); 375 if (firstName != null && firstName.trim().length() > 0) { 376 title.append(firstName); 377 } 378 379 String lastName = userAdapter.getLastName(); 380 if (lastName != null && lastName.trim().length() > 0) { 381 if (title.length() > 0) { 382 title.append(" "); 383 } 384 title.append(lastName); 385 } 386 387 if (title.length() > 0) { 388 return title.toString(); 389 } 390 391 return userName; 392 393 } 394 395 protected void notifyEvent(CoreSession coreSession, DocumentModel document, NuxeoPrincipal principal, 396 String eventId, Map<String, Serializable> properties) { 397 if (properties == null) { 398 properties = new HashMap<>(); 399 } 400 EventContext eventContext; 401 if (document != null) { 402 properties.put(CoreEventConstants.REPOSITORY_NAME, document.getRepositoryName()); 403 properties.put(CoreEventConstants.DOC_LIFE_CYCLE, document.getCurrentLifeCycleState()); 404 eventContext = new DocumentEventContext(coreSession, principal, document); 405 } else { 406 eventContext = new EventContextImpl(coreSession, principal); 407 } 408 eventContext.setProperties(properties); 409 Event event = eventContext.newEvent(eventId); 410 Framework.getService(EventProducer.class).fireEvent(event); 411 } 412 413 protected class UnrestrictedUserWorkspaceFinder extends UnrestrictedSessionRunner { 414 415 protected DocumentModel userWorkspace; 416 417 protected String userName; 418 419 protected UnrestrictedUserWorkspaceFinder(String userName, DocumentModel context) { 420 super(context.getCoreSession().getRepositoryName(), userName); 421 this.userName = userName; 422 } 423 424 @Override 425 public void run() { 426 userWorkspace = getCurrentUserPersonalWorkspace(null, userName, session); 427 if (userWorkspace != null) { 428 userWorkspace.detach(true); 429 } 430 } 431 432 public DocumentModel getDetachedUserWorkspace() { 433 return userWorkspace; 434 } 435 } 436 437 protected UserWorkspaceServiceImplComponent getComponent() { 438 return Framework.getService(UserWorkspaceServiceImplComponent.class); 439 } 440 441 protected abstract DocumentModel doCreateUserWorkspacesRoot(CoreSession unrestrictedSession, PathRef rootRef); 442 443 protected abstract DocumentModel initCreateUserWorkspacesRoot(CoreSession unrestrictedSession, DocumentModel doc); 444 445 protected abstract DocumentModel doCreateUserWorkspace(CoreSession unrestrictedSession, PathRef wsRef, 446 String username); 447 448 protected abstract DocumentModel initCreateUserWorkspace(CoreSession unrestrictedSession, DocumentModel doc, 449 String username); 450 451 @Override 452 public void invalidate() { 453 targetDomainName = null; 454 } 455 456 /** 457 * @since 10.3 458 */ 459 @Override 460 public DocumentModel getUserDefaultCollectionsRoot(CoreSession session) { 461 DocumentModel defaultCollectionsRoot = createDefaultCollectionsRoot(session, 462 getCurrentUserPersonalWorkspace(session)); 463 return session.getOrCreateDocument(defaultCollectionsRoot, doc -> initDefaultCollectionsRoot(session, doc)); 464 } 465 466 /** 467 * @since 10.3 468 */ 469 @Override 470 public DocumentModel getUserFavorites(CoreSession session) { 471 DocumentModel location = getCurrentUserPersonalWorkspace(session); 472 if (location == null) { 473 // no location => no favorites (transient user for instance) 474 return null; 475 } 476 DocumentModel favorites = createFavorites(session, location); 477 return session.getOrCreateDocument(favorites, doc -> initCreateFavorites(session, doc)); 478 } 479 480 /** 481 * @since 10.3 482 */ 483 protected Locale getLocale(final CoreSession session) { 484 Locale locale = Framework.getService(LocaleProvider.class).getLocale(session); 485 if (locale == null) { 486 locale = Locale.getDefault(); 487 } 488 return new Locale(locale.getLanguage()); 489 } 490 491 /** 492 * @since 10.3 493 */ 494 protected DocumentModel createFavorites(CoreSession session, DocumentModel userWorkspace) { 495 DocumentModel doc = session.createDocumentModel(userWorkspace.getPath().toString(), 496 FavoritesConstants.DEFAULT_FAVORITES_NAME, FavoritesConstants.FAVORITES_TYPE); 497 String title; 498 try { 499 title = I18NUtils.getMessageString("messages", FavoritesConstants.DEFAULT_FAVORITES_TITLE, new Object[0], 500 getLocale(session)); 501 } catch (MissingResourceException e) { 502 title = FavoritesConstants.DEFAULT_FAVORITES_NAME; 503 } 504 doc.setPropertyValue("dc:title", title); 505 doc.setPropertyValue("dc:description", ""); 506 return doc; 507 } 508 509 /** 510 * @since 10.3 511 */ 512 protected DocumentModel initCreateFavorites(CoreSession session, DocumentModel favorites) { 513 ACP acp = new ACPImpl(); 514 ACE denyEverything = new ACE(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING, false); 515 ACE allowEverything = new ACE(session.getPrincipal().getName(), SecurityConstants.EVERYTHING, true); 516 ACL acl = new ACLImpl(); 517 acl.setACEs(new ACE[] { allowEverything, denyEverything }); 518 acp.addACL(acl); 519 favorites.setACP(acp, true); 520 return favorites; 521 } 522 523 /** 524 * @since 10.3 525 */ 526 protected DocumentModel createDefaultCollectionsRoot(final CoreSession session, DocumentModel userWorkspace) { 527 DocumentModel doc = session.createDocumentModel(userWorkspace.getPath().toString(), 528 CollectionConstants.DEFAULT_COLLECTIONS_NAME, CollectionConstants.COLLECTIONS_TYPE); 529 String title; 530 try { 531 title = I18NUtils.getMessageString("messages", CollectionConstants.DEFAULT_COLLECTIONS_TITLE, new Object[0], 532 getLocale(session)); 533 } catch (MissingResourceException e) { 534 title = CollectionConstants.DEFAULT_COLLECTIONS_TITLE; 535 } 536 doc.setPropertyValue("dc:title", title); 537 doc.setPropertyValue("dc:description", ""); 538 return doc; 539 } 540 541 /** 542 * @since 10.3 543 */ 544 protected DocumentModel initDefaultCollectionsRoot(final CoreSession session, DocumentModel collectionsRoot) { 545 ACP acp = new ACPImpl(); 546 ACE denyEverything = new ACE(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING, false); 547 ACE allowEverything = new ACE(session.getPrincipal().getName(), SecurityConstants.EVERYTHING, true); 548 ACL acl = new ACLImpl(); 549 acl.setACEs(new ACE[] { allowEverything, denyEverything }); 550 acp.addACL(acl); 551 collectionsRoot.setACP(acp, true); 552 return collectionsRoot; 553 } 554 555}