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 }