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