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}