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