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