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