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