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