package de.deepamehta.workspaces;
import de.deepamehta.config.ConfigDefinition;
import de.deepamehta.config.ConfigModificationRole;
import de.deepamehta.config.ConfigService;
import de.deepamehta.config.ConfigTarget;
import de.deepamehta.facets.FacetsService;
import de.deepamehta.topicmaps.TopicmapsService;
import de.deepamehta.core.Association;
import de.deepamehta.core.AssociationDefinition;
import de.deepamehta.core.AssociationType;
import de.deepamehta.core.DeepaMehtaObject;
import de.deepamehta.core.DeepaMehtaType;
import de.deepamehta.core.Topic;
import de.deepamehta.core.TopicType;
import de.deepamehta.core.osgi.PluginActivator;
import de.deepamehta.core.service.Cookies;
import de.deepamehta.core.service.DirectivesResponse;
import de.deepamehta.core.service.Inject;
import de.deepamehta.core.service.Transactional;
import de.deepamehta.core.service.accesscontrol.SharingMode;
import de.deepamehta.core.service.event.IntroduceAssociationTypeListener;
import de.deepamehta.core.service.event.IntroduceTopicTypeListener;
import de.deepamehta.core.service.event.PostCreateAssociationListener;
import de.deepamehta.core.service.event.PostCreateTopicListener;
import de.deepamehta.core.service.event.PreDeleteTopicListener;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Consumes;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.Logger;
@Path("/workspace")
@Consumes("application/json")
@Produces("application/json")
public class WorkspacesPlugin extends PluginActivator implements WorkspacesService, IntroduceTopicTypeListener,
IntroduceAssociationTypeListener,
PostCreateTopicListener,
PostCreateAssociationListener,
PreDeleteTopicListener {
// ------------------------------------------------------------------------------------------------------- Constants
private static final boolean SHARING_MODE_PRIVATE_ENABLED = Boolean.parseBoolean(
System.getProperty("dm4.workspaces.private.enabled", "true"));
private static final boolean SHARING_MODE_CONFIDENTIAL_ENABLED = Boolean.parseBoolean(
System.getProperty("dm4.workspaces.confidential.enabled", "true"));
private static final boolean SHARING_MODE_COLLABORATIVE_ENABLED = Boolean.parseBoolean(
System.getProperty("dm4.workspaces.collaborative.enabled", "true"));
private static final boolean SHARING_MODE_PUBLIC_ENABLED = Boolean.parseBoolean(
System.getProperty("dm4.workspaces.public.enabled", "true"));
private static final boolean SHARING_MODE_COMMON_ENABLED = Boolean.parseBoolean(
System.getProperty("dm4.workspaces.common.enabled", "true"));
// Note: the default values are required in case no config file is in effect. This applies when DM is started
// via feature:install from Karaf. The default values must match the values defined in project POM.
// ---------------------------------------------------------------------------------------------- Instance Variables
@Inject
private FacetsService facetsService;
@Inject
private TopicmapsService topicmapsService;
@Inject
private ConfigService configService;
private Logger logger = Logger.getLogger(getClass().getName());
// -------------------------------------------------------------------------------------------------- Public Methods
// ****************************************
// *** WorkspacesService Implementation ***
// ****************************************
@POST
@Path("/{name}/{uri:[^/]*?}/{sharing_mode_uri}") // Note: default is [^/]+? // +? is a "reluctant" quantifier
@Transactional
@Override
public Topic createWorkspace(@PathParam("name") final String name, @PathParam("uri") final String uri,
@PathParam("sharing_mode_uri") final SharingMode sharingMode) {
final String operation = "Creating workspace \"" + name + "\" ";
final String info = "(uri=\"" + uri + "\", sharingMode=" + sharingMode + ")";
try {
// We suppress standard workspace assignment here as 1) a workspace itself gets no assignment at all,
// and 2) the workspace's default topicmap requires a special assignment. See step 2) below.
return dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {
@Override
public Topic call() {
logger.info(operation + info);
//
// 1) create workspace
Topic workspace = dm4.createTopic(
mf.newTopicModel(uri, "dm4.workspaces.workspace", mf.newChildTopicsModel()
.put("dm4.workspaces.name", name)
.putRef("dm4.workspaces.sharing_mode", sharingMode.getUri())));
//
// 2) create default topicmap and assign to workspace
Topic topicmap = topicmapsService.createTopicmap(TopicmapsService.DEFAULT_TOPICMAP_NAME,
TopicmapsService.DEFAULT_TOPICMAP_RENDERER, false); // isPrivate=false
// Note: user <anonymous> has no READ access to the workspace just created as it has no owner.
// So we must use the privileged assignToWorkspace() call here. This is to support the
// "DM4 Sign-up" 3rd-party plugin.
dm4.getAccessControl().assignToWorkspace(topicmap, workspace.getId());
//
return workspace;
}
});
} catch (Exception e) {
throw new RuntimeException(operation + "failed " + info, e);
}
}
// ---
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/{uri}")
@Override
public Topic getWorkspace(@PathParam("uri") String uri) {
return dm4.getAccessControl().getWorkspace(uri);
}
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/object/{id}")
@Override
public Topic getAssignedWorkspace(@PathParam("id") long objectId) {
long workspaceId = getAssignedWorkspaceId(objectId);
if (workspaceId == -1) {
return null;
}
return dm4.getTopic(workspaceId);
}
// ---
// Note: part of REST API, not part of OSGi service
@PUT
@Path("/{workspace_id}/object/{object_id}")
@Transactional
public DirectivesResponse assignToWorkspace(@PathParam("object_id") long objectId,
@PathParam("workspace_id") long workspaceId) {
try {
checkWorkspaceId(workspaceId);
_assignToWorkspace(dm4.getObject(objectId), workspaceId);
return new DirectivesResponse();
} catch (Exception e) {
throw new RuntimeException("Assigning object " + objectId + " to workspace " + workspaceId + " failed", e);
}
}
@Override
public void assignToWorkspace(DeepaMehtaObject object, long workspaceId) {
try {
checkWorkspaceId(workspaceId);
_assignToWorkspace(object, workspaceId);
} catch (Exception e) {
throw new RuntimeException("Assigning " + info(object) + " to workspace " + workspaceId + " failed", e);
}
}
@Override
public void assignTypeToWorkspace(DeepaMehtaType type, long workspaceId) {
try {
checkWorkspaceId(workspaceId);
_assignToWorkspace(type, workspaceId);
// view config topics
for (Topic configTopic : type.getViewConfig().getConfigTopics()) {
_assignToWorkspace(configTopic, workspaceId);
}
// association definitions
for (AssociationDefinition assocDef : type.getAssocDefs()) {
_assignToWorkspace(assocDef, workspaceId);
// view config topics (of association definition)
for (Topic configTopic : assocDef.getViewConfig().getConfigTopics()) {
_assignToWorkspace(configTopic, workspaceId);
}
}
} catch (Exception e) {
throw new RuntimeException("Assigning " + info(type) + " to workspace " + workspaceId + " failed", e);
}
}
// ---
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/{id}/topics")
@Override
public List<Topic> getAssignedTopics(@PathParam("id") long workspaceId) {
return dm4.getTopicsByProperty(PROP_WORKSPACE_ID, workspaceId);
}
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/{id}/assocs")
@Override
public List<Association> getAssignedAssociations(@PathParam("id") long workspaceId) {
return dm4.getAssociationsByProperty(PROP_WORKSPACE_ID, workspaceId);
}
// ---
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/{id}/topics/{topic_type_uri}")
@Override
public List<Topic> getAssignedTopics(@PathParam("id") long workspaceId,
@PathParam("topic_type_uri") String topicTypeUri) {
List<Topic> topics = dm4.getTopicsByType(topicTypeUri);
applyWorkspaceFilter(topics.iterator(), workspaceId);
return topics;
}
// Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
@GET
@Path("/{id}/assocs/{assoc_type_uri}")
@Override
public List<Association> getAssignedAssociations(@PathParam("id") long workspaceId,
@PathParam("assoc_type_uri") String assocTypeUri) {
List<Association> assocs = dm4.getAssociationsByType(assocTypeUri);
applyWorkspaceFilter(assocs.iterator(), workspaceId);
return assocs;
}
// ****************************
// *** Hook Implementations ***
// ****************************
@Override
public void preInstall() {
configService.registerConfigDefinition(new ConfigDefinition(
ConfigTarget.TYPE_INSTANCES, "dm4.accesscontrol.username",
mf.newTopicModel("dm4.workspaces.enabled_sharing_modes", mf.newChildTopicsModel()
.put("dm4.workspaces.private.enabled", SHARING_MODE_PRIVATE_ENABLED)
.put("dm4.workspaces.confidential.enabled", SHARING_MODE_CONFIDENTIAL_ENABLED)
.put("dm4.workspaces.collaborative.enabled", SHARING_MODE_COLLABORATIVE_ENABLED)
.put("dm4.workspaces.public.enabled", SHARING_MODE_PUBLIC_ENABLED)
.put("dm4.workspaces.common.enabled", SHARING_MODE_COMMON_ENABLED)
),
ConfigModificationRole.ADMIN
));
}
@Override
public void shutdown() {
// Note 1: unregistering is crucial e.g. for redeploying the Workspaces plugin. The next register call
// (at preInstall() time) would fail as the Config service already holds such a registration.
// Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the
// Workspaces plugin is stopped/started as well but at shutdown() time the Config service is already gone.
if (configService != null) {
configService.unregisterConfigDefinition("dm4.workspaces.enabled_sharing_modes");
} else {
logger.warning("Config service is already gone");
}
}
// ********************************
// *** Listener Implementations ***
// ********************************
/**
* Takes care the DeepaMehta standard types (and their parts) get an assignment to the DeepaMehta workspace.
* This is important in conjunction with access control.
* Note: type introduction is aborted if at least one of these conditions apply:
* - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this
* plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient.
* - The type is not a DeepaMehta standard type. In this case the 3rd-party plugin developer is responsible
* for doing the workspace assignment (in case the type is created programmatically while a migration).
* DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DeepaMehta standard
* type if its URI begins with "dm4."
*/
@Override
public void introduceTopicType(TopicType topicType) {
long workspaceId = workspaceIdForType(topicType);
if (workspaceId == -1) {
return;
}
//
assignTypeToWorkspace(topicType, workspaceId);
}
/**
* Takes care the DeepaMehta standard types (and their parts) get an assignment to the DeepaMehta workspace.
* This is important in conjunction with access control.
* Note: type introduction is aborted if at least one of these conditions apply:
* - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this
* plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient.
* - The type is not a DeepaMehta standard type. In this case the 3rd-party plugin developer is responsible
* for doing the workspace assignment (in case the type is created programmatically while a migration).
* DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DeepaMehta standard
* type if its URI begins with "dm4."
*/
@Override
public void introduceAssociationType(AssociationType assocType) {
long workspaceId = workspaceIdForType(assocType);
if (workspaceId == -1) {
return;
}
//
assignTypeToWorkspace(assocType, workspaceId);
}
// ---
/**
* Assigns every created topic to the current workspace.
*/
@Override
public void postCreateTopic(Topic topic) {
if (workspaceAssignmentIsSuppressed(topic)) {
return;
}
// Note: we must avoid a vicious circle that would occur when editing a workspace. A Description topic
// would be created (as no description is set when the workspace is created) and be assigned to the
// workspace itself. This would create an endless recursion while bubbling the modification timestamp.
if (isWorkspaceDescription(topic)) {
return;
}
//
long workspaceId = workspaceId();
// Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning
// the DeepaMehta workspace. This would not help in gaining data consistency because the topics created
// so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment.
// Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
// handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
// the other hand we don't have such a mechanism (and don't want one either).
if (workspaceId == -1) {
return;
}
//
assignToWorkspace(topic, workspaceId);
}
/**
* Assigns every created association to the current workspace.
*/
@Override
public void postCreateAssociation(Association assoc) {
if (workspaceAssignmentIsSuppressed(assoc)) {
return;
}
// Note: we must avoid a vicious circle that would occur when the association is an workspace assignment.
if (isWorkspaceAssignment(assoc)) {
return;
}
//
long workspaceId = workspaceId();
// Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning
// the DeepaMehta workspace. This would not help in gaining data consistency because the associations created
// so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment.
// Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
// handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
// the other hand we don't have such a mechanism (and don't want one either).
if (workspaceId == -1) {
return;
}
//
assignToWorkspace(assoc, workspaceId);
}
// ---
/**
* When a workspace is about to be deleted its entire content must be deleted.
*/
@Override
public void preDeleteTopic(Topic topic) {
if (topic.getTypeUri().equals("dm4.workspaces.workspace")) {
long workspaceId = topic.getId();
deleteWorkspaceContent(workspaceId);
}
}
// ------------------------------------------------------------------------------------------------- Private Methods
private long workspaceId() {
Cookies cookies = Cookies.get();
if (!cookies.has("dm4_workspace_id")) {
return -1;
}
return cookies.getLong("dm4_workspace_id");
}
/**
* Returns the ID of the DeepaMehta workspace or -1 to signal abortion of type introduction.
*/
private long workspaceIdForType(DeepaMehtaType type) {
return workspaceId() == -1 && isDeepaMehtaStandardType(type) ? getDeepaMehtaWorkspace().getId() : -1;
}
// ---
private long getAssignedWorkspaceId(long objectId) {
return dm4.getAccessControl().getAssignedWorkspaceId(objectId);
}
private void _assignToWorkspace(DeepaMehtaObject object, long workspaceId) {
// 1) create assignment association
facetsService.updateFacet(object, "dm4.workspaces.workspace_facet",
mf.newFacetValueModel("dm4.workspaces.workspace").putRef(workspaceId));
// Note: we are refering to an existing workspace. So we must put a topic *reference* (using putRef()).
//
// 2) store assignment property
object.setProperty(PROP_WORKSPACE_ID, workspaceId, true); // addToIndex=true
}
// ---
private void deleteWorkspaceContent(long workspaceId) {
try {
// 1) delete instances by type
// Note: also instances assigned to other workspaces must be deleted
for (Topic topicType : getAssignedTopics(workspaceId, "dm4.core.topic_type")) {
String typeUri = topicType.getUri();
for (Topic topic : dm4.getTopicsByType(typeUri)) {
topic.delete();
}
dm4.getTopicType(typeUri).delete();
}
for (Topic assocType : getAssignedTopics(workspaceId, "dm4.core.assoc_type")) {
String typeUri = assocType.getUri();
for (Association assoc : dm4.getAssociationsByType(typeUri)) {
assoc.delete();
}
dm4.getAssociationType(typeUri).delete();
}
// 2) delete remaining instances
for (Topic topic : getAssignedTopics(workspaceId)) {
topic.delete();
}
for (Association assoc : getAssignedAssociations(workspaceId)) {
assoc.delete();
}
} catch (Exception e) {
throw new RuntimeException("Deleting content of workspace " + workspaceId + " failed", e);
}
}
// --- Helper ---
private boolean isDeepaMehtaStandardType(DeepaMehtaType type) {
return type.getUri().startsWith("dm4.");
}
private boolean isWorkspaceDescription(Topic topic) {
return topic.getTypeUri().equals("dm4.workspaces.description");
}
private boolean isWorkspaceAssignment(Association assoc) {
// Note: the current user might have no READ permission for the potential workspace.
// This is the case e.g. when a newly created User Account is assigned to the new user's private workspace.
return dm4.getAccessControl().isWorkspaceAssignment(assoc);
}
// ---
/**
* Returns the DeepaMehta workspace or throws an exception if it doesn't exist.
*/
private Topic getDeepaMehtaWorkspace() {
return getWorkspace(DEEPAMEHTA_WORKSPACE_URI);
}
private void applyWorkspaceFilter(Iterator<? extends DeepaMehtaObject> objects, long workspaceId) {
while (objects.hasNext()) {
DeepaMehtaObject object = objects.next();
if (getAssignedWorkspaceId(object.getId()) != workspaceId) {
objects.remove();
}
}
}
/**
* Checks if the topic with the specified ID exists and is a Workspace. If not, an exception is thrown.
*
* ### TODO: principle copy in AccessControlImpl.checkWorkspaceId()
*/
private void checkWorkspaceId(long topicId) {
String typeUri = dm4.getTopic(topicId).getTypeUri();
if (!typeUri.equals("dm4.workspaces.workspace")) {
throw new IllegalArgumentException("Topic " + topicId + " is not a workspace (but of type \"" + typeUri +
"\")");
}
}
/**
* Returns true if standard workspace assignment is currently suppressed for the current thread.
*/
private boolean workspaceAssignmentIsSuppressed(DeepaMehtaObject object) {
boolean suppressed = dm4.getAccessControl().workspaceAssignmentIsSuppressed();
if (suppressed) {
logger.fine("Standard workspace assignment for " + info(object) + " SUPPRESSED");
}
return suppressed;
}
// ---
// ### FIXME: copied from Access Control
// ### TODO: add shortInfo() to DeepaMehtaObject interface
private String info(DeepaMehtaObject object) {
if (object instanceof TopicType) {
return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
} else if (object instanceof AssociationType) {
return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
} else if (object instanceof Topic) {
return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
"\")";
} else if (object instanceof Association) {
return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
} else {
throw new RuntimeException("Unexpected object: " + object);
}
}
}