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