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