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 }