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}