001package de.deepamehta.files;
002
003import de.deepamehta.files.event.CheckDiskQuotaListener;
004import de.deepamehta.config.ConfigDefinition;
005import de.deepamehta.config.ConfigModificationRole;
006import de.deepamehta.config.ConfigService;
007import de.deepamehta.config.ConfigTarget;
008
009import de.deepamehta.core.Association;
010import de.deepamehta.core.DeepaMehtaObject;
011import de.deepamehta.core.Topic;
012import de.deepamehta.core.model.AssociationModel;
013import de.deepamehta.core.model.ChildTopicsModel;
014import de.deepamehta.core.model.SimpleValue;
015import de.deepamehta.core.model.TopicModel;
016import de.deepamehta.core.model.TopicRoleModel;
017import de.deepamehta.core.osgi.PluginActivator;
018import de.deepamehta.core.service.Cookies;
019import de.deepamehta.core.service.DeepaMehtaEvent;
020import de.deepamehta.core.service.EventListener;
021import de.deepamehta.core.service.Inject;
022import de.deepamehta.core.service.Transactional;
023import de.deepamehta.core.service.accesscontrol.AccessControl;
024import de.deepamehta.core.service.accesscontrol.Operation;
025import de.deepamehta.core.service.event.StaticResourceFilterListener;
026import de.deepamehta.core.util.DeepaMehtaUtils;
027import de.deepamehta.core.util.JavaUtils;
028
029import org.apache.commons.io.IOUtils;
030
031import javax.ws.rs.Consumes;
032import javax.ws.rs.GET;
033import javax.ws.rs.Path;
034import javax.ws.rs.PathParam;
035import javax.ws.rs.POST;
036import javax.ws.rs.Produces;
037import javax.ws.rs.WebApplicationException;
038import javax.ws.rs.core.Response.Status;
039
040import javax.servlet.http.HttpServletRequest;
041import javax.servlet.http.HttpServletResponse;
042
043import java.awt.Desktop;
044import java.io.FileOutputStream;
045import java.io.InputStream;
046import java.io.IOException;
047import java.io.File;
048import java.net.URL;
049import java.util.concurrent.Callable;
050import java.util.logging.Logger;
051import java.util.regex.Matcher;
052import java.util.regex.Pattern;
053
054
055
056@Path("/files")
057@Produces("application/json")
058public class FilesPlugin extends PluginActivator implements FilesService, StaticResourceFilterListener, PathMapper {
059
060    // ------------------------------------------------------------------------------------------------------- Constants
061
062    public static final String FILE_REPOSITORY_PATH = System.getProperty("dm4.filerepo.path", "/");
063    public static final boolean FILE_REPOSITORY_PER_WORKSPACE = Boolean.getBoolean("dm4.filerepo.per_workspace");
064    public static final int DISK_QUOTA_MB = Integer.getInteger("dm4.filerepo.disk_quota", -1);
065    // Note: the default values are required in case no config file is in effect. This applies when DM is started
066    // via feature:install from Karaf. The default value must match the value defined in project POM.
067
068    private static final String FILE_REPOSITORY_URI = "/filerepo";
069
070    private static final String WORKSPACE_DIRECTORY_PREFIX = "/workspace-";
071    private static final Pattern PER_WORKSPACE_PATH_PATTERN = Pattern.compile(WORKSPACE_DIRECTORY_PREFIX + "(\\d+).*");
072
073    // Events
074    public static DeepaMehtaEvent CHECK_DISK_QUOTA = new DeepaMehtaEvent(CheckDiskQuotaListener.class) {
075        @Override
076        public void dispatch(EventListener listener, Object... params) {
077            ((CheckDiskQuotaListener) listener).checkDiskQuota(
078                (String) params[0], (Long) params[1], (Long) params[2]
079            );
080        }
081    };
082
083    // ---------------------------------------------------------------------------------------------- Instance Variables
084
085    @Inject
086    private ConfigService configService;
087
088    private Logger logger = Logger.getLogger(getClass().getName());
089
090    // -------------------------------------------------------------------------------------------------- Public Methods
091
092
093
094    // ***********************************
095    // *** FilesService Implementation ***
096    // ***********************************
097
098
099
100    // === File System Representation ===
101
102    @GET
103    @Path("/file/{path}")
104    @Transactional
105    @Override
106    public Topic getFileTopic(@PathParam("path") String repoPath) {
107        String operation = "Creating File topic for repository path \"" + repoPath + "\"";
108        try {
109            logger.info(operation);
110            //
111            // 1) pre-checks
112            File file = absolutePath(repoPath);     // throws FileRepositoryException
113            checkExistence(file);                   // throws FileRepositoryException
114            //
115            // 2) check if topic already exists
116            Topic fileTopic = fetchFileTopic(repoPath(file));
117            if (fileTopic != null) {
118                logger.info(operation + " SKIPPED -- already exists");
119                return fileTopic.loadChildTopics();
120            }
121            // 3) create topic
122            return createFileTopic(file);
123        } catch (FileRepositoryException e) {
124            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
125        } catch (Exception e) {
126            throw new RuntimeException(operation + " failed", e);
127        }
128    }
129
130    @GET
131    @Path("/folder/{path}")
132    @Transactional
133    @Override
134    public Topic getFolderTopic(@PathParam("path") String repoPath) {
135        String operation = "Creating Folder topic for repository path \"" + repoPath + "\"";
136        try {
137            logger.info(operation);
138            //
139            // 1) pre-checks
140            File file = absolutePath(repoPath);     // throws FileRepositoryException
141            checkExistence(file);                   // throws FileRepositoryException
142            //
143            // 2) check if topic already exists
144            Topic folderTopic = fetchFolderTopic(repoPath(file));
145            if (folderTopic != null) {
146                logger.info(operation + " SKIPPED -- already exists");
147                return folderTopic.loadChildTopics();
148            }
149            // 3) create topic
150            return createFolderTopic(file);
151        } catch (FileRepositoryException e) {
152            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
153        } catch (Exception e) {
154            throw new RuntimeException(operation + " failed", e);
155        }
156    }
157
158    // ---
159
160    @GET
161    @Path("/parent/{id}/file/{path}")
162    @Transactional
163    @Override
164    public Topic getChildFileTopic(@PathParam("id") long folderTopicId, @PathParam("path") String repoPath) {
165        Topic topic = getFileTopic(repoPath);
166        createFolderAssociation(folderTopicId, topic);
167        return topic;
168    }
169
170    @GET
171    @Path("/parent/{id}/folder/{path}")
172    @Transactional
173    @Override
174    public Topic getChildFolderTopic(@PathParam("id") long folderTopicId, @PathParam("path") String repoPath) {
175        Topic topic = getFolderTopic(repoPath);
176        createFolderAssociation(folderTopicId, topic);
177        return topic;
178    }
179
180
181
182    // === File Repository ===
183
184    @POST
185    @Path("/{path}")
186    @Consumes("multipart/form-data")
187    @Transactional
188    @Override
189    public StoredFile storeFile(UploadedFile file, @PathParam("path") String repoPath) {
190        String operation = "Storing " + file + " at repository path \"" + repoPath + "\"";
191        try {
192            logger.info(operation);
193            // 1) pre-checks
194            File directory = absolutePath(repoPath);    // throws FileRepositoryException
195            checkExistence(directory);                  // throws FileRepositoryException
196            //
197            // 2) store file
198            File repoFile = unusedPath(directory, file);
199            file.write(repoFile);
200            //
201            // 3) create topic
202            Topic fileTopic = createFileTopic(repoFile);
203            return new StoredFile(repoFile.getName(), repoPath(fileTopic), fileTopic.getId());
204        } catch (FileRepositoryException e) {
205            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
206        } catch (Exception e) {
207            throw new RuntimeException(operation + " failed", e);
208        }
209    }
210
211    // Note: this is not a resource method. So we don't throw a WebApplicationException here.
212    @Override
213    public Topic createFile(InputStream in, String repoPath) {
214        String operation = "Creating file (from input stream) at repository path \"" + repoPath + "\"";
215        try {
216            logger.info(operation);
217            // 1) pre-checks
218            File file = absolutePath(repoPath);         // throws FileRepositoryException
219            //
220            // 2) store file
221            FileOutputStream out = new FileOutputStream(file);
222            IOUtils.copy(in, out);
223            in.close();
224            out.close();
225            //
226            // 3) create topic
227            // ### TODO: think about overwriting an existing file.
228            // ### FIXME: in this case the existing file topic is not updated and might reflect e.g. the wrong size.
229            return getFileTopic(repoPath);
230        } catch (Exception e) {
231            throw new RuntimeException(operation + " failed", e);
232        }
233    }
234
235    @POST
236    @Path("/{path}/folder/{folder_name}")
237    @Override
238    public void createFolder(@PathParam("folder_name") String folderName, @PathParam("path") String repoPath) {
239        String operation = "Creating folder \"" + folderName + "\" at repository path \"" + repoPath + "\"";
240        try {
241            logger.info(operation);
242            // 1) pre-checks
243            File directory = absolutePath(repoPath);    // throws FileRepositoryException
244            checkExistence(directory);                  // throws FileRepositoryException
245            //
246            // 2) create directory
247            File repoFile = path(directory, folderName);
248            if (repoFile.exists()) {
249                throw new RuntimeException("File or directory \"" + repoFile + "\" already exists");
250            }
251            //
252            boolean success = repoFile.mkdir();
253            //
254            if (!success) {
255                throw new RuntimeException("File.mkdir() failed (file=\"" + repoFile + "\")");
256            }
257        } catch (FileRepositoryException e) {
258            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
259        } catch (Exception e) {
260            throw new RuntimeException(operation + " failed", e);
261        }
262    }
263
264    // ---
265
266    @GET
267    @Path("/{path}/info")
268    @Override
269    public ResourceInfo getResourceInfo(@PathParam("path") String repoPath) {
270        String operation = "Getting resource info for repository path \"" + repoPath + "\"";
271        try {
272            logger.info(operation);
273            //
274            File file = absolutePath(repoPath);     // throws FileRepositoryException
275            checkExistence(file);                   // throws FileRepositoryException
276            //
277            return new ResourceInfo(file);
278        } catch (FileRepositoryException e) {
279            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
280        } catch (Exception e) {
281            throw new RuntimeException(operation + " failed", e);
282        }
283    }
284
285    @GET
286    @Path("/{path}")
287    @Override
288    public DirectoryListing getDirectoryListing(@PathParam("path") String repoPath) {
289        String operation = "Getting directory listing for repository path \"" + repoPath + "\"";
290        try {
291            logger.info(operation);
292            //
293            File directory = absolutePath(repoPath);    // throws FileRepositoryException
294            checkExistence(directory);                  // throws FileRepositoryException
295            //
296            return new DirectoryListing(directory, this);
297            // ### TODO: if directory is no directory send NOT FOUND
298        } catch (FileRepositoryException e) {
299            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
300        } catch (Exception e) {
301            throw new RuntimeException(operation + " failed", e);
302        }
303    }
304
305    @Override
306    public String getRepositoryPath(URL url) {
307        String operation = "Checking for file repository URL (\"" + url + "\")";
308        try {
309            if (!DeepaMehtaUtils.isDeepaMehtaURL(url)) {
310                logger.info(operation + " => null");
311                return null;
312            }
313            //
314            String path = url.getPath();
315            if (!path.startsWith(FILE_REPOSITORY_URI)) {
316                logger.info(operation + " => null");
317                return null;
318            }
319            // ### TODO: compare to repoPath(HttpServletRequest request) in both regards, cutting off + 1, and decoding
320            String repoPath = path.substring(FILE_REPOSITORY_URI.length());
321            logger.info(operation + " => \"" + repoPath + "\"");
322            return repoPath;
323        } catch (Exception e) {
324            throw new RuntimeException(operation + " failed", e);
325        }
326    }
327
328    // ---
329
330    // Note: this is not a resource method. So we don't throw a WebApplicationException here.
331    // To access a file remotely use the /filerepo resource.
332    @Override
333    public File getFile(String repoPath) {
334        String operation = "Accessing the file/directory at repository path \"" + repoPath + "\"";
335        try {
336            logger.info(operation);
337            //
338            File file = absolutePath(repoPath);     // throws FileRepositoryException
339            checkExistence(file);                   // throws FileRepositoryException
340            return file;
341        } catch (Exception e) {
342            throw new RuntimeException(operation + " failed", e);
343        }
344    }
345
346    // Note: this is not a resource method. So we don't throw a WebApplicationException here.
347    // To access a file remotely use the /filerepo resource.
348    @Override
349    public File getFile(long fileTopicId) {
350        String operation = "Accessing the file/directory of File/Folder topic " + fileTopicId;
351        try {
352            logger.info(operation);
353            //
354            String repoPath = repoPath(fileTopicId);
355            File file = absolutePath(repoPath);     // throws FileRepositoryException
356            checkExistence(file);                   // throws FileRepositoryException
357            return file;
358        } catch (Exception e) {
359            throw new RuntimeException(operation + " failed", e);
360        }
361    }
362
363    // ---
364
365    @Override
366    public boolean fileExists(String repoPath) {
367        String operation = "Checking existence of file/directory at repository path \"" + repoPath + "\"";
368        try {
369            logger.info(operation);
370            //
371            File file = absolutePath(repoPath);     // throws FileRepositoryException
372            return file.exists();
373        } catch (Exception e) {
374            throw new RuntimeException(operation + " failed", e);
375        }
376    }
377
378    // ---
379
380    @Override
381    public String pathPrefix() {
382        String operation = "Constructing the repository path prefix";
383        try {
384            return FILE_REPOSITORY_PER_WORKSPACE ? _pathPrefix(getWorkspaceId()) : "";
385        } catch (Exception e) {
386            throw new RuntimeException(operation + " failed", e);
387        }
388    }
389
390    @Override
391    public String pathPrefix(long workspaceId) {
392        return FILE_REPOSITORY_PER_WORKSPACE ? _pathPrefix(workspaceId) : "";
393    }
394
395    // ---
396
397    @GET
398    @Path("/open/{id}")
399    @Override
400    public int openFile(@PathParam("id") long fileTopicId) {
401        String operation = "Opening the file of File topic " + fileTopicId;
402        try {
403            logger.info(operation);
404            //
405            String repoPath = repoPath(fileTopicId);
406            File file = absolutePath(repoPath);     // throws FileRepositoryException
407            checkExistence(file);                   // throws FileRepositoryException
408            //
409            logger.info("### Opening file \"" + file + "\"");
410            Desktop.getDesktop().open(file);
411            //
412            // Note: a HTTP GET method MUST return a non-void type
413            return 0;
414        } catch (FileRepositoryException e) {
415            throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
416        } catch (Exception e) {
417            throw new RuntimeException(operation + " failed", e);
418        }
419    }
420
421
422
423    // ****************************
424    // *** Hook Implementations ***
425    // ****************************
426
427
428
429    @Override
430    public void preInstall() {
431        configService.registerConfigDefinition(new ConfigDefinition(
432            ConfigTarget.TYPE_INSTANCES, "dm4.accesscontrol.username",
433            mf.newTopicModel("dm4.files.disk_quota", new SimpleValue(DISK_QUOTA_MB)),
434            ConfigModificationRole.ADMIN
435        ));
436    }
437
438    @Override
439    public void init() {
440        publishFileSystem(FILE_REPOSITORY_URI, FILE_REPOSITORY_PATH);
441    }
442
443    @Override
444    public void shutdown() {
445        // Note 1: unregistering is crucial e.g. for redeploying the Files plugin. The next register call
446        // (at preInstall() time) would fail as the Config service already holds such a registration.
447        // Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the
448        // Files plugin is stopped/started as well but at shutdown() time the Config service is already gone.
449        if (configService != null) {
450            configService.unregisterConfigDefinition("dm4.files.disk_quota");
451        } else {
452            logger.warning("Config service is already gone");
453        }
454    }
455
456
457
458    // ********************************
459    // *** Listener Implementations ***
460    // ********************************
461
462
463
464    @Override
465    public void staticResourceFilter(HttpServletRequest request, HttpServletResponse response) {
466        try {
467            String repoPath = repoPath(request);    // Note: the path is not canonized
468            if (repoPath != null) {
469                logger.fine("### Checking access to repository path \"" + repoPath + "\"");
470                File path = absolutePath(repoPath);             // throws FileRepositoryException 403 Forbidden
471                checkExistence(path);                           // throws FileRepositoryException 404 Not Found
472                checkAuthorization(repoPath(path), request);    // throws FileRepositoryException 401 Unauthorized
473                //
474                // prepare downloading
475                if (request.getParameter("download") != null) {
476                    logger.info("### Downloading file \"" + path + "\"");
477                    response.setHeader("Content-Disposition", "attachment;filename=" + path.getName());
478                }
479            }
480        } catch (FileRepositoryException e) {
481            throw new WebApplicationException(e, e.getStatus());
482        }
483    }
484
485
486
487    // *********************************
488    // *** PathMapper Implementation ***
489    // *********************************
490
491
492
493    @Override
494    public String repoPath(File path) {
495        try {
496            String repoPath = path.getPath();
497            //
498            if (!repoPath.startsWith(FILE_REPOSITORY_PATH)) {
499                throw new RuntimeException("Absolute path \"" + path + "\" is not a repository path");
500            }
501            // The repository path is calculated by removing the repository base path from the absolute path.
502            // Because the base path never ends with a slash the calculated repo path will always begin with a slash
503            // (it is never removed). There is one exception: the base path *does* end with a slash if it represents
504            // the entire file system, that is "/". In that case it must *not* be removed from the absolute path.
505            // In that case the repository path is the same as the absolute path.
506            if (!FILE_REPOSITORY_PATH.equals("/")) {
507                repoPath = repoPath.substring(FILE_REPOSITORY_PATH.length());
508                if (repoPath.equals("")) {
509                    repoPath = "/";
510                }
511            }
512            // ### FIXME: Windows drive letter?
513            return repoPath;
514        } catch (Exception e) {
515            throw new RuntimeException("Mapping absolute path \"" + path + "\" to a repository path failed", e);
516        }
517    }
518
519    // ------------------------------------------------------------------------------------------------- Private Methods
520
521
522
523    // === File System Representation ===
524
525    /**
526     * Fetches the File topic representing the file at the given repository path.
527     * If no such File topic exists <code>null</code> is returned.
528     *
529     * @param   repoPath        A repository path. Must be canonic.
530     */
531    private Topic fetchFileTopic(String repoPath) {
532        return fetchFileOrFolderTopic(repoPath, "dm4.files.file");
533    }
534
535    /**
536     * Fetches the Folder topic representing the folder at the given repository path.
537     * If no such Folder topic exists <code>null</code> is returned.
538     *
539     * @param   repoPath        A repository path. Must be canonic.
540     */
541    private Topic fetchFolderTopic(String repoPath) {
542        return fetchFileOrFolderTopic(repoPath, "dm4.files.folder");
543    }
544
545    // ---
546
547    /**
548     * Fetches the File/Folder topic representing the file/directory at the given repository path.
549     * If no such File/Folder topic exists <code>null</code> is returned.
550     *
551     * @param   repoPath        A repository path. Must be canonic.
552     * @param   topicTypeUri    The type of the topic to fetch: either "dm4.files.file" or "dm4.files.folder".
553     */
554    private Topic fetchFileOrFolderTopic(String repoPath, String topicTypeUri) {
555        Topic pathTopic = fetchPathTopic(repoPath);
556        if (pathTopic != null) {
557            return pathTopic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent", topicTypeUri);
558        }
559        return null;
560    }
561
562    /**
563     * @param   repoPath        A repository path. Must be canonic.
564     */
565    private Topic fetchPathTopic(String repoPath) {
566        return dm4.getTopicByValue("dm4.files.path", new SimpleValue(repoPath));
567    }
568
569    // ---
570
571    /**
572     * Creates a File topic representing the file at the given absolute path.
573     *
574     * @param   path    A canonic absolute path.
575     *
576     * @return  The created File topic.
577     */
578    private Topic createFileTopic(File path) throws Exception {
579        ChildTopicsModel childTopics = mf.newChildTopicsModel()
580            .put("dm4.files.file_name", path.getName())
581            .put("dm4.files.path", repoPath(path))  // TODO: is repo path already known by caller? Pass it?
582            .put("dm4.files.size", path.length());
583        //
584        String mediaType = JavaUtils.getFileType(path.getName());
585        if (mediaType != null) {
586            childTopics.put("dm4.files.media_type", mediaType);
587        }
588        //
589        return createFileOrFolderTopic(mf.newTopicModel("dm4.files.file", childTopics));      // throws Exception
590    }
591
592    /**
593     * Creates a Folder topic representing the directory at the given absolute path.
594     *
595     * @param   path    A canonic absolute path.
596     */
597    private Topic createFolderTopic(File path) throws Exception {
598        String folderName = null;
599        String repoPath = repoPath(path);   // Note: repo path is already calculated by caller. Could be passed.
600        File repoPathFile = new File(repoPath);
601        //
602        // if the repo path represents a workspace root directory the workspace name is used as Folder Name
603        if (FILE_REPOSITORY_PER_WORKSPACE) {
604            if (repoPathFile.getParent().equals("/")) {
605                String workspaceName = dm4.getTopic(getWorkspaceId(repoPath)).getSimpleValue().toString();
606                folderName = workspaceName;
607            }
608        }
609        // by default the directory name is used as Folder Name
610        if (folderName == null) {
611            folderName = repoPathFile.getName();    // Note: getName() of "/" returns ""
612        }
613        //
614        return createFileOrFolderTopic(mf.newTopicModel("dm4.files.folder", mf.newChildTopicsModel()
615            .put("dm4.files.folder_name", folderName)
616            .put("dm4.files.path", repoPath)));     // throws Exception
617    }
618
619    // ---
620
621    /**
622     * @param   repoPath        A repository path. Must be canonic.
623     */
624    private Topic createFileOrFolderTopic(final TopicModel model) throws Exception {
625        // We suppress standard workspace assignment here as File and Folder topics require a special assignment
626        Topic topic = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {  // throws Exception
627            @Override
628            public Topic call() {
629                return dm4.createTopic(model);
630            }
631        });
632        createWorkspaceAssignment(topic, repoPath(topic));
633        return topic;
634    }
635
636    /**
637     * @param   topic   a File topic, or a Folder topic.
638     */
639    private void createFolderAssociation(final long folderTopicId, Topic topic) {
640        try {
641            final long topicId = topic.getId();
642            boolean exists = dm4.getAssociations(folderTopicId, topicId, "dm4.core.aggregation").size() > 0;
643            if (!exists) {
644                // We suppress standard workspace assignment as the folder association requires a special assignment
645                Association assoc = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Association>() {
646                    @Override
647                    public Association call() {
648                        return dm4.createAssociation(mf.newAssociationModel("dm4.core.aggregation",
649                            mf.newTopicRoleModel(folderTopicId, "dm4.core.parent"),
650                            mf.newTopicRoleModel(topicId,       "dm4.core.child")
651                        ));
652                    }
653                });
654                createWorkspaceAssignment(assoc, repoPath(topic));
655            }
656        } catch (Exception e) {
657            throw new RuntimeException("Creating association to Folder topic " + folderTopicId + " failed", e);
658        }
659    }
660
661    // ---
662
663    /**
664     * Creates a workspace assignment for a File topic, a Folder topic, or a folder association (type "Aggregation").
665     * The workspce is calculated from both, the "dm4.filerepo.per_workspace" flag and the given repository path.
666     *
667     * @param   object  a File topic, a Folder topic, or a folder association (type "Aggregation").
668     */
669    private void createWorkspaceAssignment(DeepaMehtaObject object, String repoPath) {
670        try {
671            AccessControl ac = dm4.getAccessControl();
672            long workspaceId = FILE_REPOSITORY_PER_WORKSPACE ? getWorkspaceId(repoPath) : ac.getDeepaMehtaWorkspaceId();
673            ac.assignToWorkspace(object, workspaceId);
674        } catch (Exception e) {
675            throw new RuntimeException("Creating workspace assignment for File/Folder topic or folder association " +
676                "failed", e);
677        }
678    }
679
680
681
682    // === File Repository ===
683
684    /**
685     * Maps a repository path to an absolute path.
686     * <p>
687     * Checks the repository path to fight directory traversal attacks.
688     *
689     * @param   repoPath    A repository path. Relative to the repository base path.
690     *                      Must begin with slash, no slash at the end.
691     *
692     * @return  The canonized absolute path.
693     */
694    private File absolutePath(String repoPath) throws FileRepositoryException {
695        try {
696            File repo = new File(FILE_REPOSITORY_PATH);
697            //
698            if (!repo.exists()) {
699                throw new RuntimeException("File repository \"" + repo + "\" does not exist");
700            }
701            //
702            String _repoPath = repoPath;
703            if (FILE_REPOSITORY_PER_WORKSPACE) {
704                String pathPrefix;
705                if (repoPath.equals("/")) {
706                    pathPrefix = _pathPrefix(getWorkspaceId());
707                    _repoPath = pathPrefix;
708                } else {
709                    pathPrefix = _pathPrefix(getWorkspaceId(repoPath));
710                }
711                createWorkspaceFileRepository(new File(repo, pathPrefix));
712            }
713            //
714            repo = new File(repo, _repoPath);
715            //
716            return checkPath(repo);         // throws FileRepositoryException 403 Forbidden
717        } catch (FileRepositoryException e) {
718            throw e;
719        } catch (Exception e) {
720            throw new RuntimeException("Mapping repository path \"" + repoPath + "\" to an absolute path failed", e);
721        }
722    }
723
724    // ---
725
726    /**
727     * Checks if the absolute path represents a directory traversal attack.
728     * If so a FileRepositoryException (403 Forbidden) is thrown.
729     *
730     * @param   path    The absolute path to check.
731     *
732     * @return  The canonized absolute path.
733     */
734    private File checkPath(File path) throws FileRepositoryException, IOException {
735        // Note: a directory path returned by getCanonicalPath() never contains a "/" at the end.
736        // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well.
737        path = path.getCanonicalFile();     // throws IOException
738        boolean pointsToRepository = path.getPath().startsWith(FILE_REPOSITORY_PATH);
739        //
740        logger.fine("Checking path \"" + path + "\"\n  dm4.filerepo.path=" +
741            "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "PATH OK" : "FORBIDDEN"));
742        //
743        if (!pointsToRepository) {
744            throw new FileRepositoryException("\"" + path + "\" does not point to file repository", Status.FORBIDDEN);
745        }
746        //
747        return path;
748    }
749
750    private void checkExistence(File path) throws FileRepositoryException {
751        boolean exists = path.exists();
752        //
753        logger.fine("Checking existence of \"" + path + "\" => " + (exists ? "EXISTS" : "NOT FOUND"));
754        //
755        if (!exists) {
756            throw new FileRepositoryException("File or directory \"" + path + "\" does not exist", Status.NOT_FOUND);
757        }
758    }
759
760    /**
761     * Checks if the user associated with a request is authorized to access a repository file.
762     * If not authorized a FileRepositoryException (401 Unauthorized) is thrown.
763     *
764     * @param   repoPath    The repository path of the file to check. Must be canonic.
765     * @param   request     The request.
766     */
767    private void checkAuthorization(String repoPath, HttpServletRequest request) throws FileRepositoryException {
768        try {
769            if (FILE_REPOSITORY_PER_WORKSPACE) {
770                // We check authorization for the repository path by checking access to the corresponding File topic.
771                Topic fileTopic = fetchFileTopic(repoPath);
772                if (fileTopic != null) {
773                    // We must perform access control for the fetchFileTopic() call manually here.
774                    //
775                    // Although the AccessControlPlugin's CheckTopicReadAccessListener kicks in, the request is *not*
776                    // injected into the AccessControlPlugin letting fetchFileTopic() effectively run as "System".
777                    //
778                    // Note: checkAuthorization() is called (indirectly) from an OSGi HTTP service static resource
779                    // HttpContext. JAX-RS is not involved here. That's why no JAX-RS injection takes place.
780                    String username = dm4.getAccessControl().getUsername(request);
781                    long fileTopicId = fileTopic.getId();
782                    if (!dm4.getAccessControl().hasPermission(username, Operation.READ, fileTopicId)) {
783                        throw new FileRepositoryException(userInfo(username) + " has no READ permission for " +
784                            "repository path \"" + repoPath + "\" (File topic ID=" + fileTopicId + ")",
785                            Status.UNAUTHORIZED);
786                    }
787                } else {
788                    throw new RuntimeException("Missing File topic for repository path \"" + repoPath + "\"");
789                }
790            }
791        } catch (FileRepositoryException e) {
792            throw e;
793        } catch (Exception e) {
794            throw new RuntimeException("Checking authorization for repository path \"" + repoPath + "\" failed", e);
795        }
796    }
797
798    private String userInfo(String username) {
799        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
800    }
801
802    // ---
803
804    /**
805     * Constructs an absolute path from an absolute path and a file name.
806     *
807     * @param   directory   An absolute path.
808     *
809     * @return  The constructed absolute path.
810     */
811    private File path(File directory, String fileName) {
812        return new File(directory, fileName);
813    }
814
815    /**
816     * Constructs an absolute path for storing an uploaded file.
817     * If a file with that name already exists in the specified directory it remains untouched and the uploaded file
818     * is stored with a unique name (by adding a number).
819     *
820     * @param   directory   The directory to store the uploaded file to.
821     *                      A canonic absolute path.
822     *
823     * @return  The canonized absolute path.
824     */
825    private File unusedPath(File directory, UploadedFile file) {
826        return JavaUtils.findUnusedFile(path(directory, file.getName()));
827    }
828
829    // ---
830
831    // Note: there is also a public repoPath() method (part of the PathMapper API).
832    // It maps an absolute path to a repository path.
833
834    /**
835     * Returns the repository path of a File/Folder topic.
836     *
837     * @return  The repository path, is canonic.
838     */
839    private String repoPath(long fileTopicId) {
840        return repoPath(dm4.getTopic(fileTopicId));
841    }
842
843    /**
844     * Returns the repository path of a File/Folder topic.
845     *
846     * @return  The repository path, is canonic.
847     */
848    private String repoPath(Topic topic) {
849        return topic.getChildTopics().getString("dm4.files.path");
850    }
851
852    /**
853     * Returns the repository path of a filerepo request.
854     *
855     * @return  The repository path or <code>null</code> if the request is not a filerepo request.
856     *          Note: the returned path is <i>not</i> canonized.
857     */
858    private String repoPath(HttpServletRequest request) {
859        String repoPath = null;
860        String requestURI = request.getRequestURI();
861        if (requestURI.startsWith(FILE_REPOSITORY_URI)) {
862            // Note: the request URI is e.g. /filerepo/%2Fworkspace-1821%2Flogo-escp-europe.gif
863            // +1 cuts off the slash following /filerepo
864            repoPath = requestURI.substring(FILE_REPOSITORY_URI.length() + 1);
865            repoPath = JavaUtils.decodeURIComponent(repoPath);
866        }
867        return repoPath;
868    }
869
870    // --- Per-workspace file repositories ---
871
872    private void createWorkspaceFileRepository(File repo) {
873        try {
874            if (!repo.exists()) {
875                if (repo.mkdir()) {
876                    logger.info("### Per-workspace file repository created: \"" + repo + "\"");
877                } else {
878                    throw new RuntimeException("Directory \"" + repo + "\" not created successfully");
879                }
880            }
881        } catch (Exception e) {
882            throw new RuntimeException("Creating per-workspace file repository failed", e);
883        }
884    }
885
886    // ---
887
888    private long getWorkspaceId() {
889        Cookies cookies = Cookies.get();
890        if (!cookies.has("dm4_workspace_id")) {
891            throw new RuntimeException("If \"dm4.filerepo.per_workspace\" is set the request requires a " +
892                "\"dm4_workspace_id\" cookie");
893        }
894        return cookies.getLong("dm4_workspace_id");
895    }
896
897    private long getWorkspaceId(String repoPath) {
898        Matcher m = PER_WORKSPACE_PATH_PATTERN.matcher(repoPath);
899        if (!m.matches()) {
900            throw new RuntimeException("No workspace recognized in repository path \"" + repoPath + "\"");
901        }
902        long workspaceId = Long.parseLong(m.group(1));
903        return workspaceId;
904    }
905
906    // ---
907
908    private String _pathPrefix(long workspaceId) {
909        return WORKSPACE_DIRECTORY_PREFIX + workspaceId;
910    }
911}