001package de.deepamehta.core.impl;
002
003import de.deepamehta.core.Association;
004import de.deepamehta.core.DeepaMehtaObject;
005import de.deepamehta.core.RelatedTopic;
006import de.deepamehta.core.Topic;
007import de.deepamehta.core.model.AssociationModel;
008import de.deepamehta.core.model.DeepaMehtaObjectModel;
009import de.deepamehta.core.model.SimpleValue;
010import de.deepamehta.core.model.RelatedTopicModel;
011import de.deepamehta.core.model.TopicModel;
012import de.deepamehta.core.service.accesscontrol.AccessControl;
013import de.deepamehta.core.service.accesscontrol.Credentials;
014import de.deepamehta.core.service.accesscontrol.Operation;
015import de.deepamehta.core.service.accesscontrol.SharingMode;
016import de.deepamehta.core.util.ContextTracker;
017
018import javax.servlet.http.HttpServletRequest;
019import javax.servlet.http.HttpSession;
020
021import java.util.ArrayList;
022import java.util.List;
023import java.util.concurrent.Callable;
024import java.util.logging.Logger;
025
026
027
028class AccessControlImpl implements AccessControl {
029
030    // ------------------------------------------------------------------------------------------------------- Constants
031
032    // Type URIs
033    // ### TODO: move to dm4.core namespace?
034    // ### TODO: copy in AccessControlPlugin.java
035    private static final String TYPE_MEMBERSHIP    = "dm4.accesscontrol.membership";
036    private static final String TYPE_USERNAME      = "dm4.accesscontrol.username";
037    // ### TODO: copy in TopicmapsPlugin.java
038    private static final String ASSOCIATION_MAPCONTEXT = "dm4.topicmaps.association_mapcontext";
039    //
040    private static final String TYPE_EMAIL_ADDRESS = "dm4.contacts.email_address";
041    // ### TODO: copy in ConfigPlugin.java
042    private static final String ASSOC_TYPE_USER_MAILBOX = "org.deepamehta.signup.user_mailbox";
043    private static final String ASSOC_TYPE_CONFIGURATION = "dm4.config.configuration";
044    private static final String ROLE_TYPE_CONFIGURABLE   = "dm4.config.configurable";
045    private static final String ROLE_TYPE_DEFAULT = "dm4.core.default";
046
047    // Property URIs
048    // ### TODO: move to dm4.core namespace?
049    // ### TODO: copy in AccessControlPlugin.java
050    private static final String PROP_CREATOR  = "dm4.accesscontrol.creator";
051    // ### TODO: copy in AccessControlPlugin.java
052    private static final String PROP_OWNER = "dm4.accesscontrol.owner";
053    // ### TODO: copy in WorkspacesPlugin.java
054    private static final String PROP_WORKSPACE_ID = "dm4.workspaces.workspace_id";
055
056    // Workspace URIs
057    // ### TODO: copy in WorkspaceService.java
058    private static final String DEEPAMEHTA_WORKSPACE_URI = "dm4.workspaces.deepamehta";
059    // ### TODO: copy in AccessControlService.java
060    private static final String ADMINISTRATION_WORKSPACE_URI = "dm4.workspaces.administration";
061    private static final String SYSTEM_WORKSPACE_URI = "dm4.workspaces.system";
062
063    private long systemWorkspaceId = -1;    // initialized lazily
064
065    // ---------------------------------------------------------------------------------------------- Instance Variables
066
067    // used for workspace assignment suppression
068    private ContextTracker contextTracker = new ContextTracker();
069
070    private PersistenceLayer pl;
071    private ModelFactoryImpl mf;
072
073    private Logger logger = Logger.getLogger(getClass().getName());
074
075    // ---------------------------------------------------------------------------------------------------- Constructors
076
077    AccessControlImpl(PersistenceLayer pl) {
078        this.pl = pl;
079        this.mf = pl.mf;
080    }
081
082    // -------------------------------------------------------------------------------------------------- Public Methods
083
084
085
086    // === Permissions ===
087
088    @Override
089    public boolean hasPermission(String username, Operation operation, long objectId) {
090        String typeUri = null;
091        try {
092            typeUri = getTypeUri(objectId);
093            //
094            // Note: private topicmaps are treated special. The topicmap's workspace assignment doesn't matter here.
095            // Also "operation" doesn't matter as READ/WRITE access is always granted/denied together.
096            if (typeUri.equals("dm4.topicmaps.topicmap") && isTopicmapPrivate(objectId)) {
097                return isCreator(username, objectId);
098            }
099            //
100            long workspaceId;
101            if (typeUri.equals("dm4.workspaces.workspace")) {
102                workspaceId = objectId;
103            } else {
104                workspaceId = getAssignedWorkspaceId(objectId);
105                if (workspaceId == -1) {
106                    // fallback when no workspace is assigned
107                    return permissionIfNoWorkspaceIsAssigned(operation, objectId, typeUri);
108                }
109            }
110            //
111            return _hasPermission(username, operation, workspaceId);
112        } catch (Exception e) {
113            throw new RuntimeException("Checking permission for object " + objectId + " failed (typeUri=\"" + typeUri +
114                "\", " + userInfo(username) + ", operation=" + operation + ")", e);
115        }
116    }
117
118    // ---
119
120    /**
121     * @param   username        the logged in user, or <code>null</code> if no user is logged in.
122     * @param   workspaceId     the ID of the workspace that is relevant for the permission check. Is never -1.
123     */
124     @Override
125     public boolean hasReadPermission(String username, long workspaceId) {
126        SharingMode sharingMode = getSharingMode(workspaceId);
127        switch (sharingMode) {
128        case PRIVATE:
129            return isOwner(username, workspaceId);
130        case CONFIDENTIAL:
131            return isOwner(username, workspaceId) || isMember(username, workspaceId);
132        case COLLABORATIVE:
133            return isOwner(username, workspaceId) || isMember(username, workspaceId);
134        case PUBLIC:
135            // Note: the System workspace is treated special: although it is a public workspace
136            // its content is readable only for logged in users.
137            return workspaceId != getSystemWorkspaceId() || username != null;
138        case COMMON:
139            return true;
140        default:
141            throw new RuntimeException(sharingMode + " is an unsupported sharing mode");
142        }
143    }
144
145    /**
146     * @param   username        the logged in user, or <code>null</code> if no user is logged in.
147     * @param   workspaceId     the ID of the workspace that is relevant for the permission check. Is never -1.
148     */
149     @Override
150     public boolean hasWritePermission(String username, long workspaceId) {
151        SharingMode sharingMode = getSharingMode(workspaceId);
152        switch (sharingMode) {
153        case PRIVATE:
154            return isOwner(username, workspaceId);
155        case CONFIDENTIAL:
156            return isOwner(username, workspaceId);
157        case COLLABORATIVE:
158            return isOwner(username, workspaceId) || isMember(username, workspaceId);
159        case PUBLIC:
160            return isOwner(username, workspaceId) || isMember(username, workspaceId);
161        case COMMON:
162            return true;
163        default:
164            throw new RuntimeException(sharingMode + " is an unsupported sharing mode");
165        }
166    }
167
168
169
170    // === User Accounts ===
171
172    @Override
173    public Topic checkCredentials(Credentials cred) {
174        TopicModelImpl usernameTopic = null;
175        try {
176            usernameTopic = _getUsernameTopic(cred.username);
177            if (usernameTopic == null) {
178                return null;
179            }
180            if (!matches(usernameTopic, cred.password)) {
181                return null;
182            }
183            return usernameTopic.instantiate();
184        } catch (Exception e) {
185            throw new RuntimeException("Checking credentials for user \"" + cred.username +
186                "\" failed (usernameTopic=" + usernameTopic + ")", e);
187        }
188    }
189
190    @Override
191    public void changePassword(Credentials cred) {
192        try {
193            logger.info("##### Changing password for user \"" + cred.username + "\"");
194            TopicModelImpl userAccount = _getUserAccount(_getUsernameTopicOrThrow(cred.username));
195            userAccount.update(mf.newTopicModel(mf.newChildTopicsModel()
196                .put("dm4.accesscontrol.password", cred.password)
197            ));
198        } catch (Exception e) {
199            throw new RuntimeException("Changing password for user \"" + cred.username + "\" failed", e);
200        }
201    }
202
203    // ---
204
205    @Override
206    public Topic getUsernameTopic(String username) {
207        TopicModelImpl usernameTopic = _getUsernameTopic(username);
208        return usernameTopic != null ? usernameTopic.instantiate() : null;
209    }
210
211    @Override
212    public Topic getPrivateWorkspace(String username) {
213        for (TopicModelImpl workspace : fetchTopicsByOwner(username, "dm4.workspaces.workspace")) {
214            if (getSharingMode(workspace.getId()) == SharingMode.PRIVATE) {
215                return workspace.instantiate();
216            }
217        }
218        throw new RuntimeException("User \"" + username + "\" has no private workspace");
219    }
220
221    @Override
222    public boolean isMember(String username, long workspaceId) {
223        try {
224            if (username == null) {
225                return false;
226            }
227            // Note: direct storage access is required here
228            AssociationModel membership = pl.fetchAssociation(TYPE_MEMBERSHIP,
229                _getUsernameTopicOrThrow(username).getId(), workspaceId, "dm4.core.default", "dm4.core.default");
230            return membership != null;
231        } catch (Exception e) {
232            throw new RuntimeException("Checking membership of user \"" + username + "\" and workspace " +
233                workspaceId + " failed", e);
234        }
235    }
236
237    @Override
238    public String getCreator(long objectId) {
239        return pl.hasProperty(objectId, PROP_CREATOR) ? (String) pl.fetchProperty(objectId, PROP_CREATOR) : null;
240    }
241
242
243
244    // === Session ===
245
246    @Override
247    public String getUsername(HttpServletRequest request) {
248        try {
249            HttpSession session = request.getSession(false);    // create=false
250            if (session == null) {
251                return null;
252            }
253            return username(session);
254        } catch (IllegalStateException e) {
255            // Note: this happens if request is a proxy object (injected by Jersey) and a request method is called
256            // outside request scope. This is the case while system startup.
257            return null;    // user is unknown
258        }
259    }
260
261    @Override
262    public Topic getUsernameTopic(HttpServletRequest request) {
263        String username = getUsername(request);
264        if (username == null) {
265            return null;
266        }
267        return _getUsernameTopicOrThrow(username).instantiate();
268    }
269
270    @Override
271    public String username(HttpSession session) {
272        String username = (String) session.getAttribute("username");
273        if (username == null) {
274            throw new RuntimeException("Session data inconsistency: \"username\" attribute is missing");
275        }
276        return username;
277    }
278
279
280
281    // === Workspaces / Memberships ===
282
283    @Override
284    public Topic getWorkspace(String uri) {
285        TopicModelImpl workspace = fetchTopic("uri", uri);
286        if (workspace == null) {
287            throw new RuntimeException("Workspace \"" + uri + "\" does not exist");
288        }
289        return workspace.instantiate();
290    }
291
292    // ---
293
294    @Override
295    public long getDeepaMehtaWorkspaceId() {
296        return getWorkspace(DEEPAMEHTA_WORKSPACE_URI).getId();
297    }
298
299    @Override
300    public long getAdministrationWorkspaceId() {
301        return getWorkspace(ADMINISTRATION_WORKSPACE_URI).getId();
302    }
303
304    @Override
305    public long getSystemWorkspaceId() {
306        if (systemWorkspaceId == -1) {
307            // Note: fetching the System workspace topic though the Core service would involve a permission check
308            // and run in a vicious circle. So direct storage access is required here.
309            TopicModel workspace = fetchTopic("uri", SYSTEM_WORKSPACE_URI);
310            // Note: the Access Control plugin creates the System workspace before it performs its first permission
311            // check.
312            if (workspace == null) {
313                throw new RuntimeException("The System workspace does not exist");
314            }
315            //
316            systemWorkspaceId = workspace.getId();
317        }
318        return systemWorkspaceId;
319    }
320
321    // ---
322
323    @Override
324    public long getAssignedWorkspaceId(long objectId) {
325        try {
326            long workspaceId = -1;
327            if (pl.hasProperty(objectId, PROP_WORKSPACE_ID)) {
328                workspaceId = (Long) pl.fetchProperty(objectId, PROP_WORKSPACE_ID);
329                checkWorkspaceId(workspaceId);
330            }
331            return workspaceId;
332        } catch (Exception e) {
333            throw new RuntimeException("Workspace assignment of object " + objectId + " can't be determined", e);
334        }
335    }
336
337    @Override
338    public void assignToWorkspace(DeepaMehtaObject object, long workspaceId) {
339        try {
340            // create assignment association
341            pl.createAssociation("dm4.core.aggregation",
342                object.getModel().createRoleModel("dm4.core.parent"),
343                mf.newTopicRoleModel(workspaceId, "dm4.core.child")
344            );
345            // store assignment property
346            object.setProperty(PROP_WORKSPACE_ID, workspaceId, true);   // addToIndex=true
347        } catch (Exception e) {
348            throw new RuntimeException("Assigning " + object + " to workspace " + workspaceId + " failed", e);
349        }
350    }
351
352    @Override
353    public boolean isWorkspaceAssignment(Association assoc) {
354        if (assoc.getTypeUri().equals("dm4.core.aggregation")) {
355            DeepaMehtaObjectModel topic = ((AssociationImpl) assoc).getModel().getPlayer("dm4.core.child");
356            if (topic != null && topic.getTypeUri().equals("dm4.workspaces.workspace")) {
357                return true;
358            }
359        }
360        return false;
361    }
362
363    // ---
364
365    @Override
366    public <V> V runWithoutWorkspaceAssignment(Callable<V> callable) throws Exception {
367        return contextTracker.run(callable);
368    }
369
370    @Override
371    public boolean workspaceAssignmentIsSuppressed() {
372        return contextTracker.runsInTrackedContext();
373    }
374
375
376
377    // === Topicmaps ===
378
379    @Override
380    public void deleteAssociationMapcontext(Association assoc) {
381        if (!assoc.getTypeUri().equals(ASSOCIATION_MAPCONTEXT)) {
382            throw new RuntimeException("Association " + assoc.getId() + " not eligible for privileged deletion (" +
383                assoc + ")");
384        }
385        ((AssociationImpl) assoc).getModel().delete();
386    }
387
388
389
390    // === Config Service ===
391
392    @Override
393    public RelatedTopic getConfigTopic(String configTypeUri, long topicId) {
394        try {
395            RelatedTopicModelImpl configTopic = pl.fetchTopicRelatedTopic(topicId, ASSOC_TYPE_CONFIGURATION,
396                ROLE_TYPE_CONFIGURABLE, ROLE_TYPE_DEFAULT, configTypeUri);
397            if (configTopic == null) {
398                throw new RuntimeException("The \"" + configTypeUri + "\" configuration topic for topic " + topicId +
399                    " is missing");
400            }
401            return configTopic.instantiate();
402        } catch (Exception e) {
403            throw new RuntimeException("Getting the \"" + configTypeUri + "\" configuration topic for topic " +
404                topicId + " failed", e);
405        }
406    }
407
408
409
410    // === Email Addresses ===
411
412    @Override
413    public String getUsername(String emailAddress) {
414        try {
415            String username = _getUsername(emailAddress);
416            if (username == null) {
417                throw new RuntimeException("No username is assigned to email address \"" + emailAddress + "\"");
418            }
419            return username;
420        } catch (Exception e) {
421            throw new RuntimeException("Getting the username for email address \"" + emailAddress + "\" failed", e);
422        }
423    }
424
425    @Override
426    public String getEmailAddress(String username) {
427        try {
428            String emailAddress = _getEmailAddress(username);
429            if (emailAddress == null) {
430                throw new RuntimeException("No email address is assigned to username \"" + username + "\"");
431            }
432            return emailAddress;
433        } catch (Exception e) {
434            throw new RuntimeException("Getting the email address for username \"" + username + "\" failed", e);
435        }
436    }
437
438    // ---
439
440    @Override
441    public boolean emailAddressExists(String emailAddress) {
442        return _getUsername(emailAddress) != null;
443    }
444
445
446
447    // ------------------------------------------------------------------------------------------------- Private Methods
448
449    /**
450     * Prerequisite: usernameTopic is not <code>null</code>.
451     *
452     * @param   password    The encoded password.
453     */
454    private boolean matches(TopicModel usernameTopic, String password) {
455        String _password = getPasswordTopic(usernameTopic).getSimpleValue().toString();  // encoded
456        return _password.equals(password);
457    }
458
459    /**
460     * Prerequisite: usernameTopic is not <code>null</code>.
461     */
462    private TopicModel getPasswordTopic(TopicModel usernameTopic) {
463        return _getPasswordTopic(_getUserAccount(usernameTopic));
464    }
465
466    /**
467     * Prerequisite: usernameTopic is not <code>null</code>.
468     */
469    private TopicModelImpl _getUserAccount(TopicModel usernameTopic) {
470        // Note: checking the credentials is performed by <anonymous> and User Accounts are private.
471        // So direct storage access is required here.
472        RelatedTopicModelImpl userAccount = pl.fetchTopicRelatedTopic(usernameTopic.getId(), "dm4.core.composition",
473            "dm4.core.child", "dm4.core.parent", "dm4.accesscontrol.user_account");
474        if (userAccount == null) {
475            throw new RuntimeException("Data inconsistency: there is no User Account topic for username \"" +
476                usernameTopic.getSimpleValue() + "\" (usernameTopic=" + usernameTopic + ")");
477        }
478        return userAccount;
479    }
480
481    /**
482     * Prerequisite: userAccount is not <code>null</code>.
483     */
484    private TopicModel _getPasswordTopic(TopicModel userAccount) {
485        // Note: we only have a (User Account) topic model at hand and we don't want instantiate a Topic.
486        // So we use direct storage access here.
487        RelatedTopicModel password = pl.fetchTopicRelatedTopic(userAccount.getId(), "dm4.core.composition",
488            "dm4.core.parent", "dm4.core.child", "dm4.accesscontrol.password");
489        if (password == null) {
490            throw new RuntimeException("Data inconsistency: there is no Password topic for User Account \"" +
491                userAccount.getSimpleValue() + "\" (userAccount=" + userAccount + ")");
492        }
493        return password;
494    }
495
496    // ---
497
498    // ### TODO: remove this workaround
499    private boolean permissionIfNoWorkspaceIsAssigned(Operation operation, long objectId, String typeUri) {
500        switch (operation) {
501        case READ:
502            logger.fine("Object " + objectId + " (typeUri=\"" + typeUri +
503                "\") is not assigned to any workspace -- READ permission is granted");
504            return true;
505        case WRITE:
506            logger.warning("Object " + objectId + " (typeUri=\"" + typeUri +
507                "\") is not assigned to any workspace -- WRITE permission is refused");
508            return false;
509        default:
510            throw new RuntimeException(operation + " is an unsupported operation");
511        }
512    }
513
514    private boolean _hasPermission(String username, Operation operation, long workspaceId) {
515        switch (operation) {
516        case READ:
517            return hasReadPermission(username, workspaceId);
518        case WRITE:
519            return hasWritePermission(username, workspaceId);
520        default:
521            throw new RuntimeException(operation + " is an unsupported operation");
522        }
523    }
524
525    // ---
526
527    /**
528     * Checks if a user is the owner of a workspace.
529     *
530     * @param   username    the logged in user, or <code>null</code> if no user is logged in.
531     *
532     * @return  <code>true</code> if the user is the owner, <code>false</code> otherwise.
533     */
534    private boolean isOwner(String username, long workspaceId) {
535        try {
536            if (username == null) {
537                return false;
538            }
539            return getOwner(workspaceId).equals(username);
540        } catch (Exception e) {
541            throw new RuntimeException("Checking ownership of workspace " + workspaceId + " and user \"" +
542                username + "\" failed", e);
543        }
544    }
545
546    private SharingMode getSharingMode(long workspaceId) {
547        // Note: direct storage access is required here
548        TopicModel sharingMode = pl.fetchTopicRelatedTopic(workspaceId, "dm4.core.aggregation", "dm4.core.parent",
549            "dm4.core.child", "dm4.workspaces.sharing_mode");
550        if (sharingMode == null) {
551            throw new RuntimeException("No sharing mode is assigned to workspace " + workspaceId);
552        }
553        return SharingMode.fromString(sharingMode.getUri());
554    }
555
556    private void checkWorkspaceId(long workspaceId) {
557        String typeUri = getTypeUri(workspaceId);
558        if (!typeUri.equals("dm4.workspaces.workspace")) {
559            throw new RuntimeException("Object " + workspaceId + " is not a workspace (but of type \"" + typeUri +
560                "\")");
561        }
562    }
563
564    // ---
565
566    private boolean isTopicmapPrivate(long topicmapId) {
567        TopicModel privateFlag = pl.fetchTopicRelatedTopic(topicmapId, "dm4.core.composition", "dm4.core.parent",
568            "dm4.core.child", "dm4.topicmaps.private");
569        if (privateFlag == null) {
570            // Note: migrated topicmaps might not have a Private child topic ### TODO: throw exception?
571            return false;   // treat as non-private
572        }
573        return privateFlag.getSimpleValue().booleanValue();
574    }
575
576    private boolean isCreator(String username, long objectId) {
577        return username != null ? username.equals(getCreator(objectId)) : false;
578    }
579
580    // ---
581
582    private String getOwner(long workspaceId) {
583        // Note: direct storage access is required here
584        if (!pl.hasProperty(workspaceId, PROP_OWNER)) {
585            throw new RuntimeException("No owner is assigned to workspace " + workspaceId);
586        }
587        return (String) pl.fetchProperty(workspaceId, PROP_OWNER);
588    }
589
590    private String getTypeUri(long objectId) {
591        // Note: direct storage access is required here
592        return (String) pl.fetchProperty(objectId, "type_uri");
593    }
594
595    // ---
596
597    private TopicModelImpl _getUsernameTopic(String username) {
598        // Note: username topics are not readable by <anonymous>.
599        // So direct storage access is required here.
600        return fetchTopic(TYPE_USERNAME, username);
601    }
602
603    private TopicModelImpl _getUsernameTopicOrThrow(String username) {
604        TopicModelImpl usernameTopic = _getUsernameTopic(username);
605        if (usernameTopic == null) {
606            throw new RuntimeException("User \"" + username + "\" does not exist");
607        }
608        return usernameTopic;
609    }
610
611    // ---
612
613    private String _getUsername(String emailAddress) {
614        String username = null;
615        for (TopicModelImpl emailAddressTopic : queryTopics(TYPE_EMAIL_ADDRESS, emailAddress)) {
616            TopicModel usernameTopic = emailAddressTopic.getRelatedTopic(ASSOC_TYPE_USER_MAILBOX,
617                "dm4.core.child", "dm4.core.parent", TYPE_USERNAME);
618            if (usernameTopic != null) {
619                if (username != null) {
620                    throw new RuntimeException("Ambiguity: the Username assignment for email address \"" +
621                        emailAddress + "\" is not unique");
622                }
623                username = usernameTopic.getSimpleValue().toString();
624            }
625        }
626        return username;
627    }
628
629    private String _getEmailAddress(String username) {
630        TopicModel emailAddress = _getUsernameTopicOrThrow(username).getRelatedTopic(ASSOC_TYPE_USER_MAILBOX,
631            "dm4.core.parent", "dm4.core.child", TYPE_EMAIL_ADDRESS);
632        return emailAddress != null ? emailAddress.getSimpleValue().toString() : null;
633    }
634
635
636
637    // === Direct Storage Access ===
638
639    /**
640     * Fetches a topic by key/value.
641     * <p>
642     * IMPORTANT: only applicable to values indexed with <code>dm4.core.key</code>.
643     *
644     * @return  the topic, or <code>null</code> if no such topic exists.
645     */
646    private TopicModelImpl fetchTopic(String key, Object value) {
647        return pl.fetchTopic(key, new SimpleValue(value));
648    }
649
650    /**
651     * Queries topics by key/value.
652     * <p>
653     * IMPORTANT: only applicable to values indexed with <code>dm4.core.fulltext</code> or
654     * <code>dm4.core.fulltext_key</code>.
655     *
656     * @return  a list, possibly empty.
657     */
658    private List<TopicModelImpl> queryTopics(String key, Object value) {
659        return pl.queryTopics(key, new SimpleValue(value));
660    }
661
662    /**
663     * Fetches topics by owner, and filter by type.
664     */
665    private List<TopicModelImpl> fetchTopicsByOwner(String username, String typeUri) {
666        List<TopicModelImpl> topics = new ArrayList();
667        for (TopicModelImpl topic : pl.fetchTopicsByProperty(PROP_OWNER, username)) {
668            if (topic.getTypeUri().equals(typeUri)) {
669                topics.add(topic);
670            }
671        }
672        return topics;
673    }
674
675
676
677    // === Logging ===
678
679    // ### TODO: there is a copy in AccessControlPlugin.java
680    private String userInfo(String username) {
681        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
682    }
683}