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