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 + " ABORTED -- 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 + " ABORTED -- 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(), 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 canonized.
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 canonized.
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 canonized.
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 canonized.
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 canonized absolute path.
575     */
576    private Topic createFileTopic(File path) throws Exception {
577        ChildTopicsModel childTopics = mf.newChildTopicsModel()
578            .put("dm4.files.file_name", path.getName())
579            .put("dm4.files.path", repoPath(path))  // Note: repo path is already calculated by caller. Could be passed.
580            .put("dm4.files.size", path.length());
581        //
582        String mediaType = JavaUtils.getFileType(path.getName());
583        if (mediaType != null) {
584            childTopics.put("dm4.files.media_type", mediaType);
585        }
586        //
587        return createFileOrFolderTopic(mf.newTopicModel("dm4.files.file", childTopics));      // throws Exception
588    }
589
590    /**
591     * Creates a Folder topic representing the directory at the given absolute path.
592     *
593     * @param   path    A canonized absolute path.
594     */
595    private Topic createFolderTopic(File path) throws Exception {
596        String folderName = null;
597        String repoPath = repoPath(path);   // Note: repo path is already calculated by caller. Could be passed.
598        File repoPathFile = new File(repoPath);
599        //
600        // if the repo path represents a workspace root directory the workspace name is used as Folder Name
601        if (FILE_REPOSITORY_PER_WORKSPACE) {
602            if (repoPathFile.getParent().equals("/")) {
603                String workspaceName = dm4.getTopic(getWorkspaceId(repoPath)).getSimpleValue().toString();
604                folderName = workspaceName;
605            }
606        }
607        // by default the directory name is used as Folder Name
608        if (folderName == null) {
609            folderName = repoPathFile.getName();    // Note: getName() of "/" returns ""
610        }
611        //
612        return createFileOrFolderTopic(mf.newTopicModel("dm4.files.folder", mf.newChildTopicsModel()
613            .put("dm4.files.folder_name", folderName)
614            .put("dm4.files.path", repoPath)));     // throws Exception
615    }
616
617    // ---
618
619    /**
620     * @param   repoPath        A repository path. Must be canonized.
621     */
622    private Topic createFileOrFolderTopic(final TopicModel model) throws Exception {
623        // We suppress standard workspace assignment here as File and Folder topics require a special assignment
624        Topic topic = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {  // throws Exception
625            @Override
626            public Topic call() {
627                return dm4.createTopic(model);
628            }
629        });
630        createWorkspaceAssignment(topic, repoPath(topic));
631        return topic;
632    }
633
634    /**
635     * @param   topic   a File topic, or a Folder topic.
636     */
637    private void createFolderAssociation(final long folderTopicId, Topic topic) {
638        try {
639            final long topicId = topic.getId();
640            boolean exists = dm4.getAssociations(folderTopicId, topicId, "dm4.core.aggregation").size() > 0;
641            if (!exists) {
642                // We suppress standard workspace assignment as the folder association requires a special assignment
643                Association assoc = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Association>() {
644                    @Override
645                    public Association call() {
646                        return dm4.createAssociation(mf.newAssociationModel("dm4.core.aggregation",
647                            mf.newTopicRoleModel(folderTopicId, "dm4.core.parent"),
648                            mf.newTopicRoleModel(topicId,       "dm4.core.child")
649                        ));
650                    }
651                });
652                createWorkspaceAssignment(assoc, repoPath(topic));
653            }
654        } catch (Exception e) {
655            throw new RuntimeException("Creating association to Folder topic " + folderTopicId + " failed", e);
656        }
657    }
658
659    // ---
660
661    /**
662     * Creates a workspace assignment for a File topic, a Folder topic, or a folder association (type "Aggregation").
663     * The workspce is calculated from both, the "dm4.filerepo.per_workspace" flag and the given repository path.
664     *
665     * @param   object  a File topic, a Folder topic, or a folder association (type "Aggregation").
666     */
667    private void createWorkspaceAssignment(DeepaMehtaObject object, String repoPath) {
668        try {
669            AccessControl ac = dm4.getAccessControl();
670            long workspaceId = FILE_REPOSITORY_PER_WORKSPACE ? getWorkspaceId(repoPath) : ac.getDeepaMehtaWorkspaceId();
671            ac.assignToWorkspace(object, workspaceId);
672        } catch (Exception e) {
673            throw new RuntimeException("Creating workspace assignment for File/Folder topic or folder association " +
674                "failed", e);
675        }
676    }
677
678
679
680    // === File Repository ===
681
682    /**
683     * Maps a repository path to an absolute path.
684     * <p>
685     * Checks the repository path to fight directory traversal attacks.
686     *
687     * @param   repoPath    A repository path. Relative to the repository base path.
688     *                      Must begin with slash, no slash at the end.
689     *
690     * @return  The canonized absolute path.
691     */
692    private File absolutePath(String repoPath) throws FileRepositoryException {
693        try {
694            File repo = new File(FILE_REPOSITORY_PATH);
695            //
696            if (!repo.exists()) {
697                throw new RuntimeException("File repository \"" + repo + "\" does not exist");
698            }
699            //
700            String _repoPath = repoPath;
701            if (FILE_REPOSITORY_PER_WORKSPACE) {
702                String pathPrefix;
703                if (repoPath.equals("/")) {
704                    pathPrefix = _pathPrefix(getWorkspaceId());
705                    _repoPath = pathPrefix;
706                } else {
707                    pathPrefix = _pathPrefix(getWorkspaceId(repoPath));
708                }
709                createWorkspaceFileRepository(new File(repo, pathPrefix));
710            }
711            //
712            repo = new File(repo, _repoPath);
713            //
714            return checkPath(repo);         // throws FileRepositoryException 403 Forbidden
715        } catch (FileRepositoryException e) {
716            throw e;
717        } catch (Exception e) {
718            throw new RuntimeException("Mapping repository path \"" + repoPath + "\" to an absolute path failed", e);
719        }
720    }
721
722    // ---
723
724    /**
725     * Checks if the absolute path represents a directory traversal attack.
726     * If so a FileRepositoryException (403 Forbidden) is thrown.
727     *
728     * @param   path    The absolute path to check.
729     *
730     * @return  The canonized absolute path.
731     */
732    private File checkPath(File path) throws FileRepositoryException, IOException {
733        // Note: a directory path returned by getCanonicalPath() never contains a "/" at the end.
734        // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well.
735        path = path.getCanonicalFile();     // throws IOException
736        boolean pointsToRepository = path.getPath().startsWith(FILE_REPOSITORY_PATH);
737        //
738        logger.fine("Checking path \"" + path + "\"\n  dm4.filerepo.path=" +
739            "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "PATH OK" : "FORBIDDEN"));
740        //
741        if (!pointsToRepository) {
742            throw new FileRepositoryException("\"" + path + "\" does not point to file repository", Status.FORBIDDEN);
743        }
744        //
745        return path;
746    }
747
748    private void checkExistence(File path) throws FileRepositoryException {
749        boolean exists = path.exists();
750        //
751        logger.fine("Checking existence of \"" + path + "\" => " + (exists ? "EXISTS" : "NOT FOUND"));
752        //
753        if (!exists) {
754            throw new FileRepositoryException("File or directory \"" + path + "\" does not exist", Status.NOT_FOUND);
755        }
756    }
757
758    /**
759     * Checks if the user associated with a request is authorized to access a repository file.
760     * If not authorized a FileRepositoryException (401 Unauthorized) is thrown.
761     *
762     * @param   repoPath    The repository path of the file to check. Must be canonized.
763     * @param   request     The request.
764     */
765    private void checkAuthorization(String repoPath, HttpServletRequest request) throws FileRepositoryException {
766        try {
767            if (FILE_REPOSITORY_PER_WORKSPACE) {
768                // We check authorization for the repository path by checking access to the corresponding File topic.
769                Topic fileTopic = fetchFileTopic(repoPath);
770                if (fileTopic != null) {
771                    // We must perform access control for the fetchFileTopic() call manually here.
772                    //
773                    // Although the AccessControlPlugin's CheckTopicReadAccessListener kicks in, the request is *not*
774                    // injected into the AccessControlPlugin letting fetchFileTopic() effectively run as "System".
775                    //
776                    // Note: checkAuthorization() is called (indirectly) from an OSGi HTTP service static resource
777                    // HttpContext. JAX-RS is not involved here. That's why no JAX-RS injection takes place.
778                    String username = dm4.getAccessControl().getUsername(request);
779                    long fileTopicId = fileTopic.getId();
780                    if (!dm4.getAccessControl().hasPermission(username, Operation.READ, fileTopicId)) {
781                        throw new FileRepositoryException(userInfo(username) + " has no READ permission for " +
782                            "repository path \"" + repoPath + "\" (File topic ID=" + fileTopicId + ")",
783                            Status.UNAUTHORIZED);
784                    }
785                } else {
786                    throw new RuntimeException("Missing File topic for repository path \"" + repoPath + "\"");
787                }
788            }
789        } catch (FileRepositoryException e) {
790            throw e;
791        } catch (Exception e) {
792            throw new RuntimeException("Checking authorization for repository path \"" + repoPath + "\" failed", e);
793        }
794    }
795
796    private String userInfo(String username) {
797        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
798    }
799
800    // ---
801
802    /**
803     * Constructs an absolute path from an absolute path and a file name.
804     *
805     * @param   directory   An absolute path.
806     *
807     * @return  The constructed absolute path.
808     */
809    private File path(File directory, String fileName) {
810        return new File(directory, fileName);
811    }
812
813    /**
814     * Constructs an absolute path for storing an uploaded file.
815     * If a file with that name already exists in the specified directory it remains untouched and the uploaded file
816     * is stored with a unique name (by adding a number).
817     *
818     * @param   directory   The directory to store the uploaded file to.
819     *                      A canonized absolute path.
820     *
821     * @return  The canonized absolute path.
822     */
823    private File unusedPath(File directory, UploadedFile file) {
824        return JavaUtils.findUnusedFile(path(directory, file.getName()));
825    }
826
827    // ---
828
829    // Note: there is also a public repoPath() method (part of the PathMapper API).
830    // It maps an absolute path to a repository path.
831
832    /**
833     * Returns the repository path of a File/Folder topic.
834     * Note: the returned path is canonized.
835     */
836    private String repoPath(long fileTopicId) {
837        return repoPath(dm4.getTopic(fileTopicId));
838    }
839
840    /**
841     * Returns the repository path of a File/Folder topic.
842     * Note: the returned path is canonized.
843     */
844    private String repoPath(Topic topic) {
845        return topic.getChildTopics().getString("dm4.files.path");
846    }
847
848    /**
849     * Returns the repository path of a filerepo request.
850     *
851     * @return  The repository path or <code>null</code> if the request is not a filerepo request.
852     *          Note: the returned path is <i>not</i> canonized.
853     */
854    private String repoPath(HttpServletRequest request) {
855        String repoPath = null;
856        String requestURI = request.getRequestURI();
857        if (requestURI.startsWith(FILE_REPOSITORY_URI)) {
858            // Note: the request URI is e.g. /filerepo/%2Fworkspace-1821%2Flogo-escp-europe.gif
859            // +1 cuts off the slash following /filerepo
860            repoPath = requestURI.substring(FILE_REPOSITORY_URI.length() + 1);
861            repoPath = JavaUtils.decodeURIComponent(repoPath);
862        }
863        return repoPath;
864    }
865
866    // --- Per-workspace file repositories ---
867
868    private void createWorkspaceFileRepository(File repo) {
869        try {
870            if (!repo.exists()) {
871                if (repo.mkdir()) {
872                    logger.info("### Per-workspace file repository created: \"" + repo + "\"");
873                } else {
874                    throw new RuntimeException("Directory \"" + repo + "\" not created successfully");
875                }
876            }
877        } catch (Exception e) {
878            throw new RuntimeException("Creating per-workspace file repository failed", e);
879        }
880    }
881
882    // ---
883
884    private long getWorkspaceId() {
885        Cookies cookies = Cookies.get();
886        if (!cookies.has("dm4_workspace_id")) {
887            throw new RuntimeException("If \"dm4.filerepo.per_workspace\" is set the request requires a " +
888                "\"dm4_workspace_id\" cookie");
889        }
890        return cookies.getLong("dm4_workspace_id");
891    }
892
893    private long getWorkspaceId(String repoPath) {
894        Matcher m = PER_WORKSPACE_PATH_PATTERN.matcher(repoPath);
895        if (!m.matches()) {
896            throw new RuntimeException("No workspace recognized in repository path \"" + repoPath + "\"");
897        }
898        long workspaceId = Long.parseLong(m.group(1));
899        return workspaceId;
900    }
901
902    // ---
903
904    private String _pathPrefix(long workspaceId) {
905        return WORKSPACE_DIRECTORY_PREFIX + workspaceId;
906    }
907}