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}