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 }