001    package de.deepamehta.plugins.files;
002    
003    import de.deepamehta.plugins.files.service.FilesService;
004    
005    import de.deepamehta.core.Topic;
006    import de.deepamehta.core.model.AssociationModel;
007    import de.deepamehta.core.model.ChildTopicsModel;
008    import de.deepamehta.core.model.SimpleValue;
009    import de.deepamehta.core.model.TopicModel;
010    import de.deepamehta.core.model.TopicRoleModel;
011    import de.deepamehta.core.osgi.PluginActivator;
012    import de.deepamehta.core.service.SecurityHandler;
013    import de.deepamehta.core.service.Transactional;
014    import de.deepamehta.core.util.DeepaMehtaUtils;
015    import de.deepamehta.core.util.JavaUtils;
016    
017    import org.apache.commons.io.IOUtils;
018    
019    import javax.ws.rs.Consumes;
020    import javax.ws.rs.GET;
021    import javax.ws.rs.HeaderParam;
022    import javax.ws.rs.Path;
023    import javax.ws.rs.PathParam;
024    import javax.ws.rs.POST;
025    import javax.ws.rs.Produces;
026    import javax.ws.rs.WebApplicationException;
027    import javax.ws.rs.core.Response.Status;
028    
029    import javax.servlet.http.HttpServletRequest;
030    import javax.servlet.http.HttpServletResponse;
031    
032    import java.awt.Desktop;
033    import java.io.FileOutputStream;
034    import java.io.InputStream;
035    import java.io.File;
036    import java.net.URL;
037    import java.util.logging.Logger;
038    
039    
040    
041    @Path("/files")
042    @Produces("application/json")
043    public class FilesPlugin extends PluginActivator implements FilesService, SecurityHandler {
044    
045        // ------------------------------------------------------------------------------------------------------- Constants
046    
047        private static final String FILE_REPOSITORY_PATH = System.getProperty("dm4.filerepo.path", "");
048        // Note: the default value is required in case no config file is in effect. This applies when DM is started
049        // via feature:install from Karaf. The default value must match the value defined in global POM.
050    
051        private static final String FILE_REPOSITORY_URI = "/filerepo";
052    
053        // ---------------------------------------------------------------------------------------------- Instance Variables
054    
055        private Logger logger = Logger.getLogger(getClass().getName());
056    
057        // -------------------------------------------------------------------------------------------------- Public Methods
058    
059    
060    
061        // ***********************************
062        // *** FilesService Implementation ***
063        // ***********************************
064    
065    
066    
067        // === File System Representation ===
068    
069        @POST
070        @Path("/file/{path:.+}")       // Note: we also match slashes as they are already decoded by an apache reverse proxy
071        @Transactional
072        @Override
073        public Topic createFileTopic(@PathParam("path") String path) {
074            String operation = "Creating file topic for repository path \"" + path + "\"";
075            try {
076                logger.info(operation);
077                // ### FIXME: drag'n'drop files from arbitrary locations (in particular different Windows drives)
078                // collides with the concept of a single-rooted file repository (as realized by the Files module).
079                // For the moment we just strip a possible drive letter to be compatible with the Files module.
080                path = JavaUtils.stripDriveLetter(path);
081                //
082                // 1) pre-checks
083                File file = enforeSecurity(path);   // throws FileRepositoryException
084                checkFileExistence(file);           // throws FileRepositoryException
085                //
086                // 2) check if topic already exists
087                Topic fileTopic = fetchFileTopic(file);
088                if (fileTopic != null) {
089                    logger.info(operation + " ABORTED -- already exists");
090                    return fileTopic;
091                }
092                // 3) create topic
093                return createFileTopic(file);
094            } catch (FileRepositoryException e) {
095                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
096            } catch (Exception e) {
097                throw new RuntimeException(operation + " failed", e);
098            }
099        }
100    
101        @POST
102        @Path("/folder/{path:.+}")     // Note: we also match slashes as they are already decoded by an apache reverse proxy
103        @Transactional
104        @Override
105        public Topic createFolderTopic(@PathParam("path") String path) {
106            String operation = "Creating folder topic for repository path \"" + path + "\"";
107            try {
108                logger.info(operation);
109                // ### FIXME: drag'n'drop folders from arbitrary locations (in particular different Windows drives)
110                // collides with the concept of a single-rooted file repository (as realized by the Files module).
111                // For the moment we just strip a possible drive letter to be compatible with the Files module.
112                path = JavaUtils.stripDriveLetter(path);
113                //
114                // 1) pre-checks
115                File file = enforeSecurity(path);   // throws FileRepositoryException
116                checkFileExistence(file);           // throws FileRepositoryException
117                //
118                // 2) check if topic already exists
119                Topic folderTopic = fetchFolderTopic(file);
120                if (folderTopic != null) {
121                    logger.info(operation + " ABORTED -- already exists");
122                    return folderTopic;
123                }
124                // 3) create topic
125                return createFolderTopic(file);
126            } catch (FileRepositoryException e) {
127                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
128            } catch (Exception e) {
129                throw new RuntimeException(operation + " failed", e);
130            }
131        }
132    
133        // ---
134    
135        @POST
136        @Path("/parent/{id}/file/{path:.+}")    // Note: we also match slashes as they are already decoded by an apache ...
137        @Transactional
138        @Override
139        public Topic createChildFileTopic(@PathParam("id") long folderTopicId, @PathParam("path") String path) {
140            Topic childTopic = createFileTopic(path);
141            associateChildTopic(folderTopicId, childTopic.getId());
142            return childTopic;
143        }
144    
145        @POST
146        @Path("/parent/{id}/folder/{path:.+}")  // Note: we also match slashes as they are already decoded by an apache ...
147        @Transactional
148        @Override
149        public Topic createChildFolderTopic(@PathParam("id") long folderTopicId, @PathParam("path") String path) {
150            Topic childTopic = createFolderTopic(path);
151            associateChildTopic(folderTopicId, childTopic.getId());
152            return childTopic;
153        }
154    
155    
156    
157        // === File Repository ===
158    
159        @POST
160        @Path("/{path:.+}")     // Note: we also match slashes as they are already decoded by an apache reverse proxy
161        @Consumes("multipart/form-data")
162        @Transactional
163        @Override
164        public StoredFile storeFile(UploadedFile file, @PathParam("path") String path) {
165            String operation = "Storing " + file + " at repository path \"" + path + "\"";
166            try {
167                logger.info(operation);
168                // 1) pre-checks
169                File directory = enforeSecurity(path);  // throws FileRepositoryException
170                checkFileExistence(directory);          // throws FileRepositoryException
171                //
172                // 2) store file
173                File repoFile = repoFile(directory, file);
174                file.write(repoFile);
175                //
176                // 3) create topic
177                Topic fileTopic = createFileTopic(repoFile);
178                return new StoredFile(repoFile.getName(), fileTopic.getId());
179            } catch (FileRepositoryException e) {
180                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
181            } catch (Exception e) {
182                throw new RuntimeException(operation + " failed", e);
183            }
184        }
185    
186        // Note: this is not a resource method. So we don't throw a WebApplicationException here.
187        @Override
188        public Topic createFile(InputStream in, String path) {
189            String operation = "Creating file (from input stream) at repository path \"" + path + "\"";
190            try {
191                logger.info(operation);
192                // 1) pre-checks
193                File file = enforeSecurity(path);       // throws FileRepositoryException
194                //
195                // 2) store file
196                FileOutputStream out = new FileOutputStream(file);
197                IOUtils.copy(in, out);
198                in.close();
199                out.close();
200                //
201                // 3) create topic
202                // ### TODO: think about overwriting an existing file.
203                // ### FIXME: in this case the existing file topic is not updated and might reflect e.g. the wrong size.
204                return createFileTopic(path);
205            } catch (Exception e) {
206                throw new RuntimeException(operation + " failed", e);
207            }
208        }
209    
210        @POST
211        @Path("/{path:.+}/folder/{folder_name}") // Note: we also match slashes as they are already decoded by an apache ...
212        @Override
213        public void createFolder(@PathParam("folder_name") String folderName, @PathParam("path") String path) {
214            String operation = "Creating folder \"" + folderName + "\" at repository path \"" + path + "\"";
215            try {
216                logger.info(operation);
217                // 1) pre-checks
218                File directory = enforeSecurity(path);  // throws FileRepositoryException
219                checkFileExistence(directory);          // throws FileRepositoryException
220                //
221                // 2) create directory
222                File repoFile = repoFile(directory, folderName);
223                if (repoFile.exists()) {
224                    throw new RuntimeException("File or directory \"" + repoFile + "\" already exists");
225                }
226                //
227                boolean success = repoFile.mkdir();
228                //
229                if (!success) {
230                    throw new RuntimeException("File.mkdir() failed (file=\"" + repoFile + "\")");
231                }
232            } catch (FileRepositoryException e) {
233                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
234            } catch (Exception e) {
235                throw new RuntimeException(operation + " failed", e);
236            }
237        }
238    
239        // ---
240    
241        @GET
242        @Path("/{path:.+}/info")    // Note: we also match slashes as they are already decoded by an apache reverse proxy
243        @Override
244        public ResourceInfo getResourceInfo(@PathParam("path") String path) {
245            String operation = "Getting resource info for repository path \"" + path + "\"";
246            try {
247                logger.info(operation);
248                //
249                File file = enforeSecurity(path);   // throws FileRepositoryException
250                checkFileExistence(file);           // throws FileRepositoryException
251                //
252                return new ResourceInfo(file);
253            } catch (FileRepositoryException e) {
254                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
255            } catch (Exception e) {
256                throw new RuntimeException(operation + " failed", e);
257            }
258        }
259    
260        @GET
261        @Path("/{path:.+}")         // Note: we also match slashes as they are already decoded by an apache reverse proxy
262        @Override
263        public DirectoryListing getDirectoryListing(@PathParam("path") String path) {
264            String operation = "Getting directory listing for repository path \"" + path + "\"";
265            try {
266                logger.info(operation);
267                //
268                File folder = enforeSecurity(path); // throws FileRepositoryException
269                checkFileExistence(folder);         // throws FileRepositoryException
270                //
271                return new DirectoryListing(folder);    // ### TODO: if folder is no directory send NOT FOUND
272            } catch (FileRepositoryException e) {
273                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
274            } catch (Exception e) {
275                throw new RuntimeException(operation + " failed", e);
276            }
277        }
278    
279        @Override
280        public String getRepositoryPath(URL url) {
281            String operation = "Checking for file repository URL (\"" + url + "\")";
282            try {
283                if (!DeepaMehtaUtils.isDeepaMehtaURL(url)) {
284                    logger.info(operation + " => null");
285                    return null;
286                }
287                //
288                String path = url.getPath();
289                if (!path.startsWith(FILE_REPOSITORY_URI)) {
290                    logger.info(operation + " => null");
291                    return null;
292                }
293                //
294                String repoPath = path.substring(FILE_REPOSITORY_URI.length());
295                logger.info(operation + " => \"" + repoPath + "\"");
296                return repoPath;
297            } catch (Exception e) {
298                throw new RuntimeException(operation + " failed", e);
299            }
300        }
301    
302        // ---
303    
304        // Note: this is not a resource method. So we don't throw a WebApplicationException here.
305        // To access a file remotely use the /filerepo resource.
306        @Override
307        public File getFile(String path) {
308            String operation = "Accessing the file at \"" + path + "\"";
309            try {
310                logger.info(operation);
311                //
312                File file = enforeSecurity(path);   // throws FileRepositoryException
313                checkFileExistence(file);           // throws FileRepositoryException
314                return file;
315            } catch (Exception e) {
316                throw new RuntimeException(operation + " failed", e);
317            }
318        }
319    
320        // Note: this is not a resource method. So we don't throw a WebApplicationException here.
321        // To access a file remotely use the /filerepo resource.
322        @Override
323        public File getFile(long fileTopicId) {
324            String operation = "Accessing the file of file topic " + fileTopicId;
325            try {
326                logger.info(operation);
327                //
328                String path = repoPath(fileTopicId);
329                File file = enforeSecurity(path);   // throws FileRepositoryException
330                checkFileExistence(file);           // throws FileRepositoryException
331                return file;
332            } catch (Exception e) {
333                throw new RuntimeException(operation + " failed", e);
334            }
335        }
336    
337        // ---
338    
339        @POST
340        @Path("/open/{id}")
341        @Override
342        public void openFile(@PathParam("id") long fileTopicId) {
343            String operation = "Opening the file of file topic " + fileTopicId;
344            try {
345                logger.info(operation);
346                //
347                String path = repoPath(fileTopicId);
348                File file = enforeSecurity(path);   // throws FileRepositoryException
349                checkFileExistence(file);           // throws FileRepositoryException
350                //
351                logger.info("### Opening file \"" + file + "\"");
352                Desktop.getDesktop().open(file);
353            } catch (FileRepositoryException e) {
354                throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus());
355            } catch (Exception e) {
356                throw new RuntimeException(operation + " failed", e);
357            }
358        }
359    
360    
361    
362        // **************************************
363        // *** SecurityHandler Implementation ***
364        // **************************************
365    
366    
367    
368        // ### TODO: to be dropped?
369        @Override
370        public boolean handleSecurity(HttpServletRequest request, HttpServletResponse response) {
371            try {
372                String path = request.getRequestURI().substring(FILE_REPOSITORY_URI.length());
373                path = JavaUtils.decodeURIComponent(path);
374                logger.info("### repository path=\"" + path + "\"");
375                File file = enforeSecurity(path);   // throws FileRepositoryException
376                checkFileExistence(file);           // throws FileRepositoryException
377                return true;
378            } catch (FileRepositoryException e) {
379                response.setStatus(e.getStatusCode());
380                return false;
381            } catch (Exception e) {
382                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
383                return false;
384            }
385        }
386    
387    
388    
389        // ****************************
390        // *** Hook Implementations ***
391        // ****************************
392    
393    
394    
395        @Override
396        public void init() {
397            publishDirectory(FILE_REPOSITORY_PATH, FILE_REPOSITORY_URI, this);      // securityHandler=this
398        }
399    
400        // ------------------------------------------------------------------------------------------------- Private Methods
401    
402    
403    
404        // === File System Representation ===
405    
406        /**
407         * Fetches the File topic representing the file at a given absolute path.
408         * If no such File topic exists <code>null</code> is returned.
409         *
410         * @param   file   An absolute path.
411         */
412        private Topic fetchFileTopic(File file) {
413            return fetchTopic(file, "dm4.files.file");
414        }
415    
416        /**
417         * Fetches the Folder topic representing the folder at a given absolute path.
418         * If no such Folder topic exists <code>null</code> is returned.
419         *
420         * @param   file   An absolute path.
421         */
422        private Topic fetchFolderTopic(File file) {
423            return fetchTopic(file, "dm4.files.folder");
424        }
425    
426        // ---
427    
428        /**
429         * @param   file   An absolute path.
430         */
431        private Topic fetchTopic(File file, String parentTypeUri) {
432            String path = repoPath(file);
433            Topic topic = dms.getTopic("dm4.files.path", new SimpleValue(path));
434            if (topic != null) {
435                return topic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent",
436                    parentTypeUri);     // ### FIXME: had fetchComposite=true
437            }
438            return null;
439        }
440    
441        // ---
442    
443        private Topic createFileTopic(File file) {
444            String mediaType = JavaUtils.getFileType(file.getName());
445            //
446            ChildTopicsModel childTopics = new ChildTopicsModel();
447            childTopics.put("dm4.files.file_name", file.getName());
448            childTopics.put("dm4.files.path",      repoPath(file));
449            if (mediaType != null) {
450                childTopics.put("dm4.files.media_type", mediaType);
451            }
452            childTopics.put("dm4.files.size",      file.length());
453            //
454            return dms.createTopic(new TopicModel("dm4.files.file", childTopics));
455        }
456    
457        private Topic createFolderTopic(File file) {
458            String folderName = file.getName();
459            String path = repoPath(file);
460            //
461            // root folder needs special treatment
462            if (path.equals("/")) {
463                folderName = "";
464            }
465            //
466            ChildTopicsModel childTopics = new ChildTopicsModel();
467            childTopics.put("dm4.files.folder_name", folderName);
468            childTopics.put("dm4.files.path",        path);
469            //
470            return dms.createTopic(new TopicModel("dm4.files.folder", childTopics));
471        }
472    
473        // ---
474    
475        private void associateChildTopic(long folderTopicId, long childTopicId) {
476            if (!childAssociationExists(folderTopicId, childTopicId)) {
477                dms.createAssociation(new AssociationModel("dm4.core.aggregation",
478                    new TopicRoleModel(folderTopicId, "dm4.core.parent"),
479                    new TopicRoleModel(childTopicId,  "dm4.core.child")
480                ));
481            }
482        }
483    
484        private boolean childAssociationExists(long folderTopicId, long childTopicId) {
485            return dms.getAssociations(folderTopicId, childTopicId, "dm4.core.aggregation").size() > 0;
486        }
487    
488    
489    
490        // === File Repository ===
491    
492        /**
493         * Constructs an absolute path from a repository path.
494         *
495         * @param   path    A repository path. Relative to the repository base path.
496         *                  Must begin with slash, no slash at the end.
497         */
498        private File repoFile(String path) {
499            return new File(FILE_REPOSITORY_PATH, path);
500        }
501    
502        /**
503         * Calculates the storage location for the uploaded file.
504         *
505         * @param   directory   An absolute path.
506         */
507        private File repoFile(File directory, UploadedFile file) {
508            return JavaUtils.findUnusedFile(repoFile(directory, file.getName()));
509        }
510    
511        /**
512         * @param   directory   An absolute path.
513         */
514        private File repoFile(File directory, String fileName) {
515            return new File(directory, fileName);
516        }
517    
518        // ---
519    
520        /**
521         * Returns the repository path that corresponds to the given absolute path.
522         *
523         * @param   file    An absolute path.
524         */
525        private String repoPath(File file) {
526            String path = file.getPath().substring(FILE_REPOSITORY_PATH.length());
527            // root folder needs special treatment
528            if (path.equals("")) {
529                path = "/";
530            }
531            //
532            return path;
533            // ### TODO: there is a principle copy in DirectoryListing
534            // ### FIXME: Windows drive letter? See DirectoryListing
535        }
536    
537        /**
538         * Returns the repository path of the given File/Folder topic.
539         */
540        private String repoPath(long fileTopicId) {
541            Topic fileTopic = dms.getTopic(fileTopicId);    // ### FIXME: had fetchComposite=true
542            return fileTopic.getChildTopics().getString("dm4.files.path");
543        }
544    
545    
546    
547        // === Security ===
548    
549        /**
550         * @param   path    A repository path. Relative to the repository base path.
551         *                  Must begin with slash, no slash at the end.
552         *
553         * @return  An absolute path.
554         */
555        private File enforeSecurity(String path) throws FileRepositoryException {
556            try {
557                // Note 1: we use getCanonicalPath() to fight directory traversal attacks (../../).
558                // Note 2: A directory path returned by getCanonicalPath() never contains a "/" at the end.
559                // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well.
560                File file = repoFile(path).getCanonicalFile();  // throws IOException
561                checkFilePath(file);                            // throws FileRepositoryException
562                //
563                return file;
564            } catch (FileRepositoryException e) {
565                throw e;
566            } catch (Exception e) {
567                throw new RuntimeException("Enforcing security for repository path \"" + path + "\" failed", e);
568            }
569        }
570    
571        // --- File Access ---
572    
573        /**
574         * Prerequisite: the file's path is canonical.
575         */
576        private void checkFilePath(File file) throws FileRepositoryException {
577            boolean pointsToRepository = file.getPath().startsWith(FILE_REPOSITORY_PATH);
578            //
579            logger.info("Checking file repository access to \"" + file + "\"\n      dm4.filerepo.path=" +
580                "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "ALLOWED" : "FORBIDDEN"));
581            //
582            if (!pointsToRepository) {
583                throw new FileRepositoryException("\"" + file + "\" is not a file repository path", Status.FORBIDDEN);
584            }
585        }
586    
587        private void checkFileExistence(File file) throws FileRepositoryException {
588            if (!file.exists()) {
589                logger.info("File or directory \"" + file + "\" does not exist => NOT FOUND");
590                throw new FileRepositoryException("\"" + file + "\" does not exist in file repository", Status.NOT_FOUND);
591            }
592        }
593    }