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}