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