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}