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}