001package systems.dmx.workspaces; 002 003import systems.dmx.config.ConfigDefinition; 004import systems.dmx.config.ConfigModificationRole; 005import systems.dmx.config.ConfigService; 006import systems.dmx.config.ConfigTarget; 007import systems.dmx.facets.FacetsService; 008import systems.dmx.topicmaps.TopicmapsService; 009 010import systems.dmx.core.Association; 011import systems.dmx.core.AssociationDefinition; 012import systems.dmx.core.AssociationType; 013import systems.dmx.core.DMXObject; 014import systems.dmx.core.DMXType; 015import systems.dmx.core.Topic; 016import systems.dmx.core.TopicType; 017import systems.dmx.core.osgi.PluginActivator; 018import systems.dmx.core.service.Cookies; 019import systems.dmx.core.service.DirectivesResponse; 020import systems.dmx.core.service.Inject; 021import systems.dmx.core.service.Transactional; 022import systems.dmx.core.service.accesscontrol.SharingMode; 023import systems.dmx.core.service.event.IntroduceAssociationTypeListener; 024import systems.dmx.core.service.event.IntroduceTopicTypeListener; 025import systems.dmx.core.service.event.PostCreateAssociationListener; 026import systems.dmx.core.service.event.PostCreateTopicListener; 027import systems.dmx.core.service.event.PreDeleteTopicListener; 028 029import org.codehaus.jettison.json.JSONObject; 030 031import javax.ws.rs.GET; 032import javax.ws.rs.POST; 033import javax.ws.rs.PUT; 034import javax.ws.rs.Consumes; 035import javax.ws.rs.Path; 036import javax.ws.rs.PathParam; 037import javax.ws.rs.Produces; 038import javax.ws.rs.QueryParam; 039import javax.ws.rs.core.Context; 040 041import javax.servlet.http.HttpServletRequest; 042 043import java.util.Iterator; 044import java.util.List; 045import java.util.concurrent.Callable; 046import java.util.logging.Level; 047import java.util.logging.Logger; 048 049 050 051@Path("/workspace") 052@Consumes("application/json") 053@Produces("application/json") 054public class WorkspacesPlugin extends PluginActivator implements WorkspacesService, IntroduceTopicTypeListener, 055 IntroduceAssociationTypeListener, 056 PostCreateTopicListener, 057 PostCreateAssociationListener, 058 PreDeleteTopicListener { 059 060 // ------------------------------------------------------------------------------------------------------- Constants 061 062 private static final boolean SHARING_MODE_PRIVATE_ENABLED = Boolean.parseBoolean( 063 System.getProperty("dmx.workspaces.private.enabled", "true")); 064 private static final boolean SHARING_MODE_CONFIDENTIAL_ENABLED = Boolean.parseBoolean( 065 System.getProperty("dmx.workspaces.confidential.enabled", "true")); 066 private static final boolean SHARING_MODE_COLLABORATIVE_ENABLED = Boolean.parseBoolean( 067 System.getProperty("dmx.workspaces.collaborative.enabled", "true")); 068 private static final boolean SHARING_MODE_PUBLIC_ENABLED = Boolean.parseBoolean( 069 System.getProperty("dmx.workspaces.public.enabled", "true")); 070 private static final boolean SHARING_MODE_COMMON_ENABLED = Boolean.parseBoolean( 071 System.getProperty("dmx.workspaces.common.enabled", "true")); 072 // Note: the default values are required in case no config file is in effect. This applies when DM is started 073 // via feature:install from Karaf. The default values must match the values defined in project POM. 074 075 // ---------------------------------------------------------------------------------------------- Instance Variables 076 077 @Inject 078 private FacetsService facetsService; 079 080 @Inject 081 private TopicmapsService topicmapsService; 082 083 @Inject 084 private ConfigService configService; 085 086 @Context 087 private HttpServletRequest request; 088 089 private Messenger me = new Messenger("systems.dmx.webclient"); 090 091 private Logger logger = Logger.getLogger(getClass().getName()); 092 093 // -------------------------------------------------------------------------------------------------- Public Methods 094 095 096 097 // **************************************** 098 // *** WorkspacesService Implementation *** 099 // **************************************** 100 101 102 103 @POST 104 @Transactional 105 @Override 106 public Topic createWorkspace(@QueryParam("name") final String name, @QueryParam("uri") final String uri, 107 @QueryParam("sharing_mode_uri") final SharingMode sharingMode) { 108 final String operation = "Creating workspace \"" + name + "\" "; 109 final String info = "(uri=\"" + uri + "\", sharingMode=" + sharingMode + ")"; 110 try { 111 // We suppress standard workspace assignment here as 1) a workspace itself gets no assignment at all, 112 // and 2) the workspace's default topicmap requires a special assignment. See step 2) below. 113 Topic workspace = dmx.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() { 114 @Override 115 public Topic call() { 116 logger.info(operation + info); 117 // 118 // 1) create workspace 119 Topic workspace = dmx.createTopic( 120 mf.newTopicModel(uri, "dmx.workspaces.workspace", mf.newChildTopicsModel() 121 .put("dmx.workspaces.name", name) 122 .putRef("dmx.workspaces.sharing_mode", sharingMode.getUri()))); 123 // 124 // 2) create default topicmap and assign to workspace 125 Topic topicmap = topicmapsService.createTopicmap( 126 TopicmapsService.DEFAULT_TOPICMAP_NAME, 127 TopicmapsService.DEFAULT_TOPICMAP_RENDERER, false // isPrivate=false 128 ); 129 // Note: user <anonymous> has no READ access to the workspace just created as it has no owner. 130 // So we must use the privileged assignToWorkspace() call here. This is to support the 131 // "DM4 Sign-up" 3rd-party plugin. 132 dmx.getAccessControl().assignToWorkspace(topicmap, workspace.getId()); 133 // 134 return workspace; 135 } 136 }); 137 me.newWorkspace(workspace); // FIXME: broadcast to eligible users only 138 return workspace; 139 } catch (Exception e) { 140 throw new RuntimeException(operation + "failed " + info, e); 141 } 142 } 143 144 // --- 145 146 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 147 @GET 148 @Path("/{uri}") 149 @Override 150 public Topic getWorkspace(@PathParam("uri") String uri) { 151 return dmx.getAccessControl().getWorkspace(uri); 152 } 153 154 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 155 @GET 156 @Path("/object/{id}") 157 @Override 158 public Topic getAssignedWorkspace(@PathParam("id") long objectId) { 159 long workspaceId = getAssignedWorkspaceId(objectId); 160 if (workspaceId == -1) { 161 return null; 162 } 163 return dmx.getTopic(workspaceId); 164 } 165 166 // --- 167 168 // Note: part of REST API, not part of OSGi service 169 @PUT 170 @Path("/{workspace_id}/object/{object_id}") 171 @Transactional 172 public DirectivesResponse assignToWorkspace(@PathParam("object_id") long objectId, 173 @PathParam("workspace_id") long workspaceId) { 174 try { 175 checkWorkspaceId(workspaceId); 176 _assignToWorkspace(dmx.getObject(objectId), workspaceId); 177 return new DirectivesResponse(); 178 } catch (Exception e) { 179 throw new RuntimeException("Assigning object " + objectId + " to workspace " + workspaceId + " failed", e); 180 } 181 } 182 183 @Override 184 public void assignToWorkspace(DMXObject object, long workspaceId) { 185 try { 186 checkWorkspaceId(workspaceId); 187 _assignToWorkspace(object, workspaceId); 188 } catch (Exception e) { 189 throw new RuntimeException("Assigning " + info(object) + " to workspace " + workspaceId + " failed", e); 190 } 191 } 192 193 @Override 194 public void assignTypeToWorkspace(DMXType type, long workspaceId) { 195 try { 196 checkWorkspaceId(workspaceId); 197 _assignToWorkspace(type, workspaceId); 198 // view config topics 199 for (Topic configTopic : type.getViewConfig().getConfigTopics()) { 200 _assignToWorkspace(configTopic, workspaceId); 201 } 202 // association definitions 203 for (AssociationDefinition assocDef : type.getAssocDefs()) { 204 _assignToWorkspace(assocDef, workspaceId); 205 // view config topics (of association definition) 206 for (Topic configTopic : assocDef.getViewConfig().getConfigTopics()) { 207 _assignToWorkspace(configTopic, workspaceId); 208 } 209 } 210 } catch (Exception e) { 211 throw new RuntimeException("Assigning " + info(type) + " to workspace " + workspaceId + " failed", e); 212 } 213 } 214 215 // --- 216 217 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 218 @GET 219 @Path("/{id}/topics") 220 @Override 221 public List<Topic> getAssignedTopics(@PathParam("id") long workspaceId) { 222 return dmx.getTopicsByProperty(PROP_WORKSPACE_ID, workspaceId); 223 } 224 225 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 226 @GET 227 @Path("/{id}/assocs") 228 @Override 229 public List<Association> getAssignedAssociations(@PathParam("id") long workspaceId) { 230 return dmx.getAssociationsByProperty(PROP_WORKSPACE_ID, workspaceId); 231 } 232 233 // --- 234 235 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 236 @GET 237 @Path("/{id}/topics/{topic_type_uri}") 238 @Override 239 public List<Topic> getAssignedTopics(@PathParam("id") long workspaceId, 240 @PathParam("topic_type_uri") String topicTypeUri) { 241 List<Topic> topics = dmx.getTopicsByType(topicTypeUri); 242 applyWorkspaceFilter(topics.iterator(), workspaceId); 243 return topics; 244 } 245 246 // Note: the "include_childs" query paramter is handled by core's JerseyResponseFilter 247 @GET 248 @Path("/{id}/assocs/{assoc_type_uri}") 249 @Override 250 public List<Association> getAssignedAssociations(@PathParam("id") long workspaceId, 251 @PathParam("assoc_type_uri") String assocTypeUri) { 252 List<Association> assocs = dmx.getAssociationsByType(assocTypeUri); 253 applyWorkspaceFilter(assocs.iterator(), workspaceId); 254 return assocs; 255 } 256 257 258 259 // **************************** 260 // *** Hook Implementations *** 261 // **************************** 262 263 264 265 @Override 266 public void preInstall() { 267 configService.registerConfigDefinition(new ConfigDefinition( 268 ConfigTarget.TYPE_INSTANCES, "dmx.accesscontrol.username", 269 mf.newTopicModel("dmx.workspaces.enabled_sharing_modes", mf.newChildTopicsModel() 270 .put("dmx.workspaces.private.enabled", SHARING_MODE_PRIVATE_ENABLED) 271 .put("dmx.workspaces.confidential.enabled", SHARING_MODE_CONFIDENTIAL_ENABLED) 272 .put("dmx.workspaces.collaborative.enabled", SHARING_MODE_COLLABORATIVE_ENABLED) 273 .put("dmx.workspaces.public.enabled", SHARING_MODE_PUBLIC_ENABLED) 274 .put("dmx.workspaces.common.enabled", SHARING_MODE_COMMON_ENABLED) 275 ), 276 ConfigModificationRole.ADMIN 277 )); 278 } 279 280 @Override 281 public void shutdown() { 282 // Note 1: unregistering is crucial e.g. for redeploying the Workspaces plugin. The next register call 283 // (at preInstall() time) would fail as the Config service already holds such a registration. 284 // Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the 285 // Workspaces plugin is stopped/started as well but at shutdown() time the Config service is already gone. 286 if (configService != null) { 287 configService.unregisterConfigDefinition("dmx.workspaces.enabled_sharing_modes"); 288 } else { 289 logger.warning("Config service is already gone"); 290 } 291 } 292 293 294 295 // ******************************** 296 // *** Listener Implementations *** 297 // ******************************** 298 299 300 301 /** 302 * Takes care the DMX standard types (and their parts) get an assignment to the DMX workspace. 303 * This is important in conjunction with access control. 304 * Note: type introduction is aborted if at least one of these conditions apply: 305 * - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this 306 * plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient. 307 * - The type is not a DMX standard type. In this case the 3rd-party plugin developer is responsible 308 * for doing the workspace assignment (in case the type is created programmatically while a migration). 309 * DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DMX standard 310 * type if its URI begins with "dmx." 311 */ 312 @Override 313 public void introduceTopicType(TopicType topicType) { 314 long workspaceId = workspaceIdForType(topicType); 315 if (workspaceId == -1) { 316 return; 317 } 318 // 319 assignTypeToWorkspace(topicType, workspaceId); 320 } 321 322 /** 323 * Takes care the DMX standard types (and their parts) get an assignment to the DMX workspace. 324 * This is important in conjunction with access control. 325 * Note: type introduction is aborted if at least one of these conditions apply: 326 * - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this 327 * plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient. 328 * - The type is not a DMX standard type. In this case the 3rd-party plugin developer is responsible 329 * for doing the workspace assignment (in case the type is created programmatically while a migration). 330 * DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DMX standard 331 * type if its URI begins with "dmx." 332 */ 333 @Override 334 public void introduceAssociationType(AssociationType assocType) { 335 long workspaceId = workspaceIdForType(assocType); 336 if (workspaceId == -1) { 337 return; 338 } 339 // 340 assignTypeToWorkspace(assocType, workspaceId); 341 } 342 343 // --- 344 345 /** 346 * Assigns every created topic to the current workspace. 347 */ 348 @Override 349 public void postCreateTopic(Topic topic) { 350 if (workspaceAssignmentIsSuppressed(topic)) { 351 return; 352 } 353 // Note: we must avoid a vicious circle that would occur when editing a workspace. A Description topic 354 // would be created (as no description is set when the workspace is created) and be assigned to the 355 // workspace itself. This would create an endless recursion while bubbling the modification timestamp. 356 if (isWorkspaceDescription(topic)) { 357 return; 358 } 359 // 360 long workspaceId = workspaceId(); 361 // Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning 362 // the DMX workspace. This would not help in gaining data consistency because the topics created 363 // so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment. 364 // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType() 365 // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on 366 // the other hand we don't have such a mechanism (and don't want one either). 367 if (workspaceId == -1) { 368 return; 369 } 370 // 371 assignToWorkspace(topic, workspaceId); 372 } 373 374 /** 375 * Assigns every created association to the current workspace. 376 */ 377 @Override 378 public void postCreateAssociation(Association assoc) { 379 if (workspaceAssignmentIsSuppressed(assoc)) { 380 return; 381 } 382 // Note: we must avoid a vicious circle that would occur when the association is an workspace assignment. 383 if (isWorkspaceAssignment(assoc)) { 384 return; 385 } 386 // 387 long workspaceId = workspaceId(); 388 // Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning 389 // the DMX workspace. This would not help in gaining data consistency because the associations created 390 // so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment. 391 // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType() 392 // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on 393 // the other hand we don't have such a mechanism (and don't want one either). 394 if (workspaceId == -1) { 395 return; 396 } 397 // 398 assignToWorkspace(assoc, workspaceId); 399 } 400 401 // --- 402 403 /** 404 * When a workspace is about to be deleted its entire content must be deleted. 405 */ 406 @Override 407 public void preDeleteTopic(Topic topic) { 408 if (topic.getTypeUri().equals("dmx.workspaces.workspace")) { 409 long workspaceId = topic.getId(); 410 deleteWorkspaceContent(workspaceId); 411 } 412 } 413 414 415 416 // ------------------------------------------------------------------------------------------------- Private Methods 417 418 private long workspaceId() { 419 Cookies cookies = Cookies.get(); 420 if (!cookies.has("dmx_workspace_id")) { 421 return -1; 422 } 423 return cookies.getLong("dmx_workspace_id"); 424 } 425 426 /** 427 * Returns the ID of the DMX workspace or -1 to signal abortion of type introduction. 428 */ 429 private long workspaceIdForType(DMXType type) { 430 return workspaceId() == -1 && isDMXStandardType(type) ? getDMXWorkspace().getId() : -1; 431 } 432 433 // --- 434 435 private long getAssignedWorkspaceId(long objectId) { 436 return dmx.getAccessControl().getAssignedWorkspaceId(objectId); 437 } 438 439 private void _assignToWorkspace(DMXObject object, long workspaceId) { 440 // 1) create assignment association 441 facetsService.updateFacet(object, "dmx.workspaces.workspace_facet", 442 mf.newFacetValueModel("dmx.workspaces.workspace").putRef(workspaceId)); 443 // Note: we are refering to an existing workspace. So we must put a topic *reference* (using putRef()). 444 // 445 // 2) store assignment property 446 object.setProperty(PROP_WORKSPACE_ID, workspaceId, true); // addToIndex=true 447 } 448 449 // --- 450 451 private void deleteWorkspaceContent(long workspaceId) { 452 try { 453 // 1) delete instances by type 454 // Note: also instances assigned to other workspaces must be deleted 455 for (Topic topicType : getAssignedTopics(workspaceId, "dmx.core.topic_type")) { 456 String typeUri = topicType.getUri(); 457 for (Topic topic : dmx.getTopicsByType(typeUri)) { 458 topic.delete(); 459 } 460 dmx.getTopicType(typeUri).delete(); 461 } 462 for (Topic assocType : getAssignedTopics(workspaceId, "dmx.core.assoc_type")) { 463 String typeUri = assocType.getUri(); 464 for (Association assoc : dmx.getAssociationsByType(typeUri)) { 465 assoc.delete(); 466 } 467 dmx.getAssociationType(typeUri).delete(); 468 } 469 // 2) delete remaining instances 470 for (Topic topic : getAssignedTopics(workspaceId)) { 471 topic.delete(); 472 } 473 for (Association assoc : getAssignedAssociations(workspaceId)) { 474 assoc.delete(); 475 } 476 } catch (Exception e) { 477 throw new RuntimeException("Deleting content of workspace " + workspaceId + " failed", e); 478 } 479 } 480 481 // --- Helper --- 482 483 private boolean isDMXStandardType(DMXType type) { 484 return type.getUri().startsWith("dmx."); 485 } 486 487 private boolean isWorkspaceDescription(Topic topic) { 488 return topic.getTypeUri().equals("dmx.workspaces.description"); 489 } 490 491 private boolean isWorkspaceAssignment(Association assoc) { 492 // Note: the current user might have no READ permission for the potential workspace. 493 // This is the case e.g. when a newly created User Account is assigned to the new user's private workspace. 494 return dmx.getAccessControl().isWorkspaceAssignment(assoc); 495 } 496 497 // --- 498 499 /** 500 * Returns the DMX workspace or throws an exception if it doesn't exist. 501 */ 502 private Topic getDMXWorkspace() { 503 return getWorkspace(DMX_WORKSPACE_URI); 504 } 505 506 private void applyWorkspaceFilter(Iterator<? extends DMXObject> objects, long workspaceId) { 507 while (objects.hasNext()) { 508 DMXObject object = objects.next(); 509 if (getAssignedWorkspaceId(object.getId()) != workspaceId) { 510 objects.remove(); 511 } 512 } 513 } 514 515 /** 516 * Checks if the topic with the specified ID exists and is a Workspace. If not, an exception is thrown. 517 * 518 * ### TODO: principle copy in AccessControlImpl.checkWorkspaceId() 519 */ 520 private void checkWorkspaceId(long topicId) { 521 String typeUri = dmx.getTopic(topicId).getTypeUri(); 522 if (!typeUri.equals("dmx.workspaces.workspace")) { 523 throw new IllegalArgumentException("Topic " + topicId + " is not a workspace (but of type \"" + typeUri + 524 "\")"); 525 } 526 } 527 528 /** 529 * Returns true if standard workspace assignment is currently suppressed for the current thread. 530 */ 531 private boolean workspaceAssignmentIsSuppressed(DMXObject object) { 532 boolean suppressed = dmx.getAccessControl().workspaceAssignmentIsSuppressed(); 533 if (suppressed) { 534 logger.fine("Standard workspace assignment for " + info(object) + " SUPPRESSED"); 535 } 536 return suppressed; 537 } 538 539 // --- 540 541 // ### FIXME: copied from Access Control 542 // ### TODO: add shortInfo() to DMXObject interface 543 private String info(DMXObject object) { 544 if (object instanceof TopicType) { 545 return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")"; 546 } else if (object instanceof AssociationType) { 547 return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")"; 548 } else if (object instanceof Topic) { 549 return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() + 550 "\")"; 551 } else if (object instanceof Association) { 552 return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")"; 553 } else { 554 throw new RuntimeException("Unexpected object: " + object); 555 } 556 } 557 558 559 560 // ------------------------------------------------------------------------------------------------- Private Classes 561 562 private class Messenger { 563 564 private String pluginUri; 565 566 private Messenger(String pluginUri) { 567 this.pluginUri = pluginUri; 568 } 569 570 // --- 571 572 private void newWorkspace(Topic workspace) { 573 try { 574 messageToAllButOne(new JSONObject() 575 .put("type", "newWorkspace") 576 .put("args", new JSONObject() 577 .put("workspace", workspace.toJSON()) 578 ) 579 ); 580 } catch (Exception e) { 581 logger.log(Level.WARNING, "Error while sending a \"newWorkspace\" message:", e); 582 } 583 } 584 585 // --- 586 587 private void messageToAllButOne(JSONObject message) { 588 dmx.getWebSocketsService().messageToAllButOne(request, pluginUri, message.toString()); 589 } 590 } 591}