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}