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