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