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