001package de.deepamehta.files; 002 003import de.deepamehta.files.event.CheckDiskQuotaListener; 004import de.deepamehta.config.ConfigDefinition; 005import de.deepamehta.config.ConfigModificationRole; 006import de.deepamehta.config.ConfigService; 007import de.deepamehta.config.ConfigTarget; 008 009import de.deepamehta.core.Association; 010import de.deepamehta.core.DeepaMehtaObject; 011import de.deepamehta.core.Topic; 012import de.deepamehta.core.model.AssociationModel; 013import de.deepamehta.core.model.ChildTopicsModel; 014import de.deepamehta.core.model.SimpleValue; 015import de.deepamehta.core.model.TopicModel; 016import de.deepamehta.core.model.TopicRoleModel; 017import de.deepamehta.core.osgi.PluginActivator; 018import de.deepamehta.core.service.Cookies; 019import de.deepamehta.core.service.DeepaMehtaEvent; 020import de.deepamehta.core.service.EventListener; 021import de.deepamehta.core.service.Inject; 022import de.deepamehta.core.service.Transactional; 023import de.deepamehta.core.service.accesscontrol.AccessControl; 024import de.deepamehta.core.service.accesscontrol.Operation; 025import de.deepamehta.core.service.event.ResourceRequestFilterListener; 026import de.deepamehta.core.util.DeepaMehtaUtils; 027import de.deepamehta.core.util.JavaUtils; 028 029import org.apache.commons.io.IOUtils; 030 031import javax.ws.rs.Consumes; 032import javax.ws.rs.GET; 033import javax.ws.rs.Path; 034import javax.ws.rs.PathParam; 035import javax.ws.rs.POST; 036import javax.ws.rs.Produces; 037import javax.ws.rs.WebApplicationException; 038import javax.ws.rs.core.Response.Status; 039 040import javax.servlet.http.HttpServletRequest; 041 042import java.awt.Desktop; 043import java.io.FileOutputStream; 044import java.io.InputStream; 045import java.io.IOException; 046import java.io.File; 047import java.net.URL; 048import java.util.concurrent.Callable; 049import java.util.logging.Logger; 050import java.util.regex.Matcher; 051import java.util.regex.Pattern; 052 053 054 055@Path("/files") 056@Produces("application/json") 057public class FilesPlugin extends PluginActivator implements FilesService, ResourceRequestFilterListener, PathMapper { 058 059 // ------------------------------------------------------------------------------------------------------- Constants 060 061 public static final String FILE_REPOSITORY_PATH = System.getProperty("dm4.filerepo.path", "/"); 062 public static final boolean FILE_REPOSITORY_PER_WORKSPACE = Boolean.getBoolean("dm4.filerepo.per_workspace"); 063 public static final int DISK_QUOTA_MB = Integer.getInteger("dm4.filerepo.disk_quota", 150); 064 // Note: the default values are required in case no config file is in effect. This applies when DM is started 065 // via feature:install from Karaf. The default value must match the value defined in project POM. 066 067 private static final String FILE_REPOSITORY_URI = "/filerepo"; 068 069 private static final String WORKSPACE_DIRECTORY_PREFIX = "/workspace-"; 070 private static final Pattern PER_WORKSPACE_PATH_PATTERN = Pattern.compile(WORKSPACE_DIRECTORY_PREFIX + "(\\d+).*"); 071 072 // Events 073 public static DeepaMehtaEvent CHECK_DISK_QUOTA = new DeepaMehtaEvent(CheckDiskQuotaListener.class) { 074 @Override 075 public void dispatch(EventListener listener, Object... params) { 076 ((CheckDiskQuotaListener) listener).checkDiskQuota( 077 (String) params[0], (Long) params[1], (Long) params[2] 078 ); 079 } 080 }; 081 082 // ---------------------------------------------------------------------------------------------- Instance Variables 083 084 @Inject 085 private ConfigService configService; 086 087 private Logger logger = Logger.getLogger(getClass().getName()); 088 089 // -------------------------------------------------------------------------------------------------- Public Methods 090 091 092 093 // *********************************** 094 // *** FilesService Implementation *** 095 // *********************************** 096 097 098 099 // === File System Representation === 100 101 @GET 102 @Path("/file/{path}") 103 @Transactional 104 @Override 105 public Topic getFileTopic(@PathParam("path") String repoPath) { 106 String operation = "Creating File topic for repository path \"" + repoPath + "\""; 107 try { 108 logger.info(operation); 109 // 110 // 1) pre-checks 111 File file = absolutePath(repoPath); // throws FileRepositoryException 112 checkExistence(file); // throws FileRepositoryException 113 // 114 // 2) check if topic already exists 115 Topic fileTopic = fetchFileTopic(repoPath(file)); 116 if (fileTopic != null) { 117 logger.info(operation + " ABORTED -- already exists"); 118 return fileTopic.loadChildTopics(); 119 } 120 // 3) create topic 121 return createFileTopic(file); 122 } catch (FileRepositoryException e) { 123 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 124 } catch (Exception e) { 125 throw new RuntimeException(operation + " failed", e); 126 } 127 } 128 129 @GET 130 @Path("/folder/{path}") 131 @Transactional 132 @Override 133 public Topic getFolderTopic(@PathParam("path") String repoPath) { 134 String operation = "Creating Folder topic for repository path \"" + repoPath + "\""; 135 try { 136 logger.info(operation); 137 // 138 // 1) pre-checks 139 File file = absolutePath(repoPath); // throws FileRepositoryException 140 checkExistence(file); // throws FileRepositoryException 141 // 142 // 2) check if topic already exists 143 Topic folderTopic = fetchFolderTopic(repoPath(file)); 144 if (folderTopic != null) { 145 logger.info(operation + " ABORTED -- already exists"); 146 return folderTopic.loadChildTopics(); 147 } 148 // 3) create topic 149 return createFolderTopic(file); 150 } catch (FileRepositoryException e) { 151 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 152 } catch (Exception e) { 153 throw new RuntimeException(operation + " failed", e); 154 } 155 } 156 157 // --- 158 159 @GET 160 @Path("/parent/{id}/file/{path}") 161 @Transactional 162 @Override 163 public Topic getChildFileTopic(@PathParam("id") long folderTopicId, @PathParam("path") String repoPath) { 164 Topic topic = getFileTopic(repoPath); 165 createFolderAssociation(folderTopicId, topic); 166 return topic; 167 } 168 169 @GET 170 @Path("/parent/{id}/folder/{path}") 171 @Transactional 172 @Override 173 public Topic getChildFolderTopic(@PathParam("id") long folderTopicId, @PathParam("path") String repoPath) { 174 Topic topic = getFolderTopic(repoPath); 175 createFolderAssociation(folderTopicId, topic); 176 return topic; 177 } 178 179 180 181 // === File Repository === 182 183 @POST 184 @Path("/{path}") 185 @Consumes("multipart/form-data") 186 @Transactional 187 @Override 188 public StoredFile storeFile(UploadedFile file, @PathParam("path") String repoPath) { 189 String operation = "Storing " + file + " at repository path \"" + repoPath + "\""; 190 try { 191 logger.info(operation); 192 // 1) pre-checks 193 File directory = absolutePath(repoPath); // throws FileRepositoryException 194 checkExistence(directory); // throws FileRepositoryException 195 // 196 // 2) store file 197 File repoFile = unusedPath(directory, file); 198 file.write(repoFile); 199 // 200 // 3) create topic 201 Topic fileTopic = createFileTopic(repoFile); 202 return new StoredFile(repoFile.getName(), fileTopic.getId()); 203 } catch (FileRepositoryException e) { 204 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 205 } catch (Exception e) { 206 throw new RuntimeException(operation + " failed", e); 207 } 208 } 209 210 // Note: this is not a resource method. So we don't throw a WebApplicationException here. 211 @Override 212 public Topic createFile(InputStream in, String repoPath) { 213 String operation = "Creating file (from input stream) at repository path \"" + repoPath + "\""; 214 try { 215 logger.info(operation); 216 // 1) pre-checks 217 File file = absolutePath(repoPath); // throws FileRepositoryException 218 // 219 // 2) store file 220 FileOutputStream out = new FileOutputStream(file); 221 IOUtils.copy(in, out); 222 in.close(); 223 out.close(); 224 // 225 // 3) create topic 226 // ### TODO: think about overwriting an existing file. 227 // ### FIXME: in this case the existing file topic is not updated and might reflect e.g. the wrong size. 228 return getFileTopic(repoPath); 229 } catch (Exception e) { 230 throw new RuntimeException(operation + " failed", e); 231 } 232 } 233 234 @POST 235 @Path("/{path}/folder/{folder_name}") 236 @Override 237 public void createFolder(@PathParam("folder_name") String folderName, @PathParam("path") String repoPath) { 238 String operation = "Creating folder \"" + folderName + "\" at repository path \"" + repoPath + "\""; 239 try { 240 logger.info(operation); 241 // 1) pre-checks 242 File directory = absolutePath(repoPath); // throws FileRepositoryException 243 checkExistence(directory); // throws FileRepositoryException 244 // 245 // 2) create directory 246 File repoFile = path(directory, folderName); 247 if (repoFile.exists()) { 248 throw new RuntimeException("File or directory \"" + repoFile + "\" already exists"); 249 } 250 // 251 boolean success = repoFile.mkdir(); 252 // 253 if (!success) { 254 throw new RuntimeException("File.mkdir() failed (file=\"" + repoFile + "\")"); 255 } 256 } catch (FileRepositoryException e) { 257 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 258 } catch (Exception e) { 259 throw new RuntimeException(operation + " failed", e); 260 } 261 } 262 263 // --- 264 265 @GET 266 @Path("/{path}/info") 267 @Override 268 public ResourceInfo getResourceInfo(@PathParam("path") String repoPath) { 269 String operation = "Getting resource info for repository path \"" + repoPath + "\""; 270 try { 271 logger.info(operation); 272 // 273 File file = absolutePath(repoPath); // throws FileRepositoryException 274 checkExistence(file); // throws FileRepositoryException 275 // 276 return new ResourceInfo(file); 277 } catch (FileRepositoryException e) { 278 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 279 } catch (Exception e) { 280 throw new RuntimeException(operation + " failed", e); 281 } 282 } 283 284 @GET 285 @Path("/{path}") 286 @Override 287 public DirectoryListing getDirectoryListing(@PathParam("path") String repoPath) { 288 String operation = "Getting directory listing for repository path \"" + repoPath + "\""; 289 try { 290 logger.info(operation); 291 // 292 File directory = absolutePath(repoPath); // throws FileRepositoryException 293 checkExistence(directory); // throws FileRepositoryException 294 // 295 return new DirectoryListing(directory, this); 296 // ### TODO: if directory is no directory send NOT FOUND 297 } catch (FileRepositoryException e) { 298 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 299 } catch (Exception e) { 300 throw new RuntimeException(operation + " failed", e); 301 } 302 } 303 304 @Override 305 public String getRepositoryPath(URL url) { 306 String operation = "Checking for file repository URL (\"" + url + "\")"; 307 try { 308 if (!DeepaMehtaUtils.isDeepaMehtaURL(url)) { 309 logger.info(operation + " => null"); 310 return null; 311 } 312 // 313 String path = url.getPath(); 314 if (!path.startsWith(FILE_REPOSITORY_URI)) { 315 logger.info(operation + " => null"); 316 return null; 317 } 318 // ### TODO: compare to repoPath(HttpServletRequest request) in both regards, cutting off + 1, and decoding 319 String repoPath = path.substring(FILE_REPOSITORY_URI.length()); 320 logger.info(operation + " => \"" + repoPath + "\""); 321 return repoPath; 322 } catch (Exception e) { 323 throw new RuntimeException(operation + " failed", e); 324 } 325 } 326 327 // --- 328 329 // Note: this is not a resource method. So we don't throw a WebApplicationException here. 330 // To access a file remotely use the /filerepo resource. 331 @Override 332 public File getFile(String repoPath) { 333 String operation = "Accessing the file/directory at repository path \"" + repoPath + "\""; 334 try { 335 logger.info(operation); 336 // 337 File file = absolutePath(repoPath); // throws FileRepositoryException 338 checkExistence(file); // throws FileRepositoryException 339 return file; 340 } catch (Exception e) { 341 throw new RuntimeException(operation + " failed", e); 342 } 343 } 344 345 // Note: this is not a resource method. So we don't throw a WebApplicationException here. 346 // To access a file remotely use the /filerepo resource. 347 @Override 348 public File getFile(long fileTopicId) { 349 String operation = "Accessing the file/directory of File/Folder topic " + fileTopicId; 350 try { 351 logger.info(operation); 352 // 353 String repoPath = repoPath(fileTopicId); 354 File file = absolutePath(repoPath); // throws FileRepositoryException 355 checkExistence(file); // throws FileRepositoryException 356 return file; 357 } catch (Exception e) { 358 throw new RuntimeException(operation + " failed", e); 359 } 360 } 361 362 // --- 363 364 @Override 365 public boolean fileExists(String repoPath) { 366 String operation = "Checking existence of file/directory at repository path \"" + repoPath + "\""; 367 try { 368 logger.info(operation); 369 // 370 File file = absolutePath(repoPath); // throws FileRepositoryException 371 return file.exists(); 372 } catch (Exception e) { 373 throw new RuntimeException(operation + " failed", e); 374 } 375 } 376 377 // --- 378 379 @Override 380 public String pathPrefix() { 381 String operation = "Constructing the repository path prefix"; 382 try { 383 return FILE_REPOSITORY_PER_WORKSPACE ? _pathPrefix(getWorkspaceId()) : ""; 384 } catch (Exception e) { 385 throw new RuntimeException(operation + " failed", e); 386 } 387 } 388 389 @Override 390 public String pathPrefix(long workspaceId) { 391 return FILE_REPOSITORY_PER_WORKSPACE ? _pathPrefix(workspaceId) : ""; 392 } 393 394 // --- 395 396 @POST 397 @Path("/open/{id}") 398 @Override 399 public void openFile(@PathParam("id") long fileTopicId) { 400 String operation = "Opening the file of File topic " + fileTopicId; 401 try { 402 logger.info(operation); 403 // 404 String repoPath = repoPath(fileTopicId); 405 File file = absolutePath(repoPath); // throws FileRepositoryException 406 checkExistence(file); // throws FileRepositoryException 407 // 408 logger.info("### Opening file \"" + file + "\""); 409 Desktop.getDesktop().open(file); 410 } catch (FileRepositoryException e) { 411 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 412 } catch (Exception e) { 413 throw new RuntimeException(operation + " failed", e); 414 } 415 } 416 417 418 419 // **************************** 420 // *** Hook Implementations *** 421 // **************************** 422 423 424 425 @Override 426 public void preInstall() { 427 configService.registerConfigDefinition(new ConfigDefinition( 428 ConfigTarget.TYPE_INSTANCES, "dm4.accesscontrol.username", 429 mf.newTopicModel("dm4.files.disk_quota", new SimpleValue(DISK_QUOTA_MB)), 430 ConfigModificationRole.ADMIN 431 )); 432 } 433 434 @Override 435 public void init() { 436 publishFileSystem(FILE_REPOSITORY_URI, FILE_REPOSITORY_PATH); 437 } 438 439 @Override 440 public void shutdown() { 441 // Note 1: unregistering is crucial e.g. for redeploying the Files plugin. The next register call 442 // (at preInstall() time) would fail as the Config service already holds such a registration. 443 // Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the 444 // Files plugin is stopped/started as well but at shutdown() time the Config service is already gone. 445 if (configService != null) { 446 configService.unregisterConfigDefinition("dm4.files.disk_quota"); 447 } else { 448 logger.warning("Config service is already gone"); 449 } 450 } 451 452 453 454 // ******************************** 455 // *** Listener Implementations *** 456 // ******************************** 457 458 459 460 @Override 461 public void resourceRequestFilter(HttpServletRequest request) { 462 try { 463 String repoPath = repoPath(request); // Note: the path is not canonized 464 if (repoPath != null) { 465 logger.fine("### Checking access to repository path \"" + repoPath + "\""); 466 File path = absolutePath(repoPath); // throws FileRepositoryException 403 Forbidden 467 checkExistence(path); // throws FileRepositoryException 404 Not Found 468 checkAuthorization(repoPath(path), request); // throws FileRepositoryException 401 Unauthorized 469 } 470 } catch (FileRepositoryException e) { 471 throw new WebApplicationException(e, e.getStatus()); 472 } 473 } 474 475 476 477 // ********************************* 478 // *** PathMapper Implementation *** 479 // ********************************* 480 481 482 483 @Override 484 public String repoPath(File path) { 485 try { 486 String repoPath = path.getPath(); 487 // 488 if (!repoPath.startsWith(FILE_REPOSITORY_PATH)) { 489 throw new RuntimeException("Absolute path \"" + path + "\" is not a repository path"); 490 } 491 // The repository path is calculated by removing the repository base path from the absolute path. 492 // Because the base path never ends with a slash the calculated repo path will always begin with a slash 493 // (it is never removed). There is one exception: the base path *does* end with a slash if it represents 494 // the entire file system, that is "/". In that case it must *not* be removed from the absolute path. 495 // In that case the repository path is the same as the absolute path. 496 if (!FILE_REPOSITORY_PATH.equals("/")) { 497 repoPath = repoPath.substring(FILE_REPOSITORY_PATH.length()); 498 if (repoPath.equals("")) { 499 repoPath = "/"; 500 } 501 } 502 // ### FIXME: Windows drive letter? 503 return repoPath; 504 } catch (Exception e) { 505 throw new RuntimeException("Mapping absolute path \"" + path + "\" to a repository path failed", e); 506 } 507 } 508 509 // ------------------------------------------------------------------------------------------------- Private Methods 510 511 512 513 // === File System Representation === 514 515 /** 516 * Fetches the File topic representing the file at the given repository path. 517 * If no such File topic exists <code>null</code> is returned. 518 * 519 * @param repoPath A repository path. Must be canonized. 520 */ 521 private Topic fetchFileTopic(String repoPath) { 522 return fetchTopic(repoPath, "dm4.files.file"); 523 } 524 525 /** 526 * Fetches the Folder topic representing the folder at the given repository path. 527 * If no such Folder topic exists <code>null</code> is returned. 528 * 529 * @param repoPath A repository path. Must be canonized. 530 */ 531 private Topic fetchFolderTopic(String repoPath) { 532 return fetchTopic(repoPath, "dm4.files.folder"); 533 } 534 535 // --- 536 537 /** 538 * Fetches the File/Folder topic representing the file/directory at the given repository path. 539 * If no such File/Folder topic exists <code>null</code> is returned. 540 * 541 * @param repoPath A repository path. Must be canonized. 542 * @param topicTypeUri The type of the topic to fetch: either "dm4.files.file" or "dm4.files.folder". 543 */ 544 private Topic fetchTopic(String repoPath, String topicTypeUri) { 545 Topic topic = dm4.getTopicByValue("dm4.files.path", new SimpleValue(repoPath)); 546 if (topic != null) { 547 return topic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent", topicTypeUri); 548 } 549 return null; 550 } 551 552 // --- 553 554 /** 555 * Creates a File topic representing the file at the given absolute path. 556 * 557 * @param path A canonized absolute path. 558 */ 559 private Topic createFileTopic(File path) throws Exception { 560 ChildTopicsModel childTopics = mf.newChildTopicsModel() 561 .put("dm4.files.file_name", path.getName()) 562 .put("dm4.files.path", repoPath(path)) // Note: repo path is already calculated by caller. Could be passed. 563 .put("dm4.files.size", path.length()); 564 // 565 String mediaType = JavaUtils.getFileType(path.getName()); 566 if (mediaType != null) { 567 childTopics.put("dm4.files.media_type", mediaType); 568 } 569 // 570 return createFileOrFolderTopic(mf.newTopicModel("dm4.files.file", childTopics)); // throws Exception 571 } 572 573 /** 574 * Creates a Folder topic representing the directory at the given absolute path. 575 * 576 * @param path A canonized absolute path. 577 */ 578 private Topic createFolderTopic(File path) throws Exception { 579 String folderName = null; 580 String repoPath = repoPath(path); // Note: repo path is already calculated by caller. Could be passed. 581 File repoPathFile = new File(repoPath); 582 // 583 // if the repo path represents a workspace root directory the workspace name is used as Folder Name 584 if (FILE_REPOSITORY_PER_WORKSPACE) { 585 if (repoPathFile.getParent().equals("/")) { 586 String workspaceName = dm4.getTopic(getWorkspaceId(repoPath)).getSimpleValue().toString(); 587 folderName = workspaceName; 588 } 589 } 590 // by default the directory name is used as Folder Name 591 if (folderName == null) { 592 folderName = repoPathFile.getName(); // Note: getName() of "/" returns "" 593 } 594 // 595 return createFolderTopic(folderName, repoPath); // throws Exception 596 } 597 598 /** 599 * Creates a Folder topic. 600 * 601 * @param folderName The folder name. 602 * @param repoPath The folder repository path. 603 */ 604 private Topic createFolderTopic(String folderName, String repoPath) throws Exception { 605 return createFileOrFolderTopic(mf.newTopicModel("dm4.files.folder", mf.newChildTopicsModel() 606 .put("dm4.files.folder_name", folderName) 607 .put("dm4.files.path", repoPath) 608 )); // throws Exception 609 } 610 611 // --- 612 613 private Topic createFileOrFolderTopic(final TopicModel model) throws Exception { 614 // We suppress standard workspace assignment here as File and Folder topics require a special assignment 615 Topic topic = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() { // throws Exception 616 @Override 617 public Topic call() { 618 return dm4.createTopic(model); 619 } 620 }); 621 createWorkspaceAssignment(topic, repoPath(topic)); 622 return topic; 623 } 624 625 /** 626 * @param topic a File topic, or a Folder topic. 627 */ 628 private void createFolderAssociation(final long folderTopicId, Topic topic) { 629 try { 630 final long topicId = topic.getId(); 631 boolean exists = dm4.getAssociations(folderTopicId, topicId, "dm4.core.aggregation").size() > 0; 632 if (!exists) { 633 // We suppress standard workspace assignment as the folder association requires a special assignment 634 Association assoc = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Association>() { 635 @Override 636 public Association call() { 637 return dm4.createAssociation(mf.newAssociationModel("dm4.core.aggregation", 638 mf.newTopicRoleModel(folderTopicId, "dm4.core.parent"), 639 mf.newTopicRoleModel(topicId, "dm4.core.child") 640 )); 641 } 642 }); 643 createWorkspaceAssignment(assoc, repoPath(topic)); 644 } 645 } catch (Exception e) { 646 throw new RuntimeException("Creating association to Folder topic " + folderTopicId + " failed", e); 647 } 648 } 649 650 // --- 651 652 /** 653 * Creates a workspace assignment for a File topic, a Folder topic, or a folder association (type "Aggregation"). 654 * The workspce is calculated from both, the "dm4.filerepo.per_workspace" flag and the given repository path. 655 * 656 * @param object a File topic, a Folder topic, or a folder association (type "Aggregation"). 657 */ 658 private void createWorkspaceAssignment(DeepaMehtaObject object, String repoPath) { 659 try { 660 AccessControl ac = dm4.getAccessControl(); 661 long workspaceId = FILE_REPOSITORY_PER_WORKSPACE ? getWorkspaceId(repoPath) : ac.getDeepaMehtaWorkspaceId(); 662 ac.assignToWorkspace(object, workspaceId); 663 } catch (Exception e) { 664 throw new RuntimeException("Creating workspace assignment for File/Folder topic or folder association " + 665 "failed", e); 666 } 667 } 668 669 670 671 // === File Repository === 672 673 /** 674 * Maps a repository path to an absolute path. 675 * Checks the repository path to fight directory traversal attacks. 676 * 677 * @param repoPath A repository path. Relative to the repository base path. 678 * Must begin with slash, no slash at the end. 679 * 680 * @return The canonized absolute path. 681 */ 682 private File absolutePath(String repoPath) throws FileRepositoryException { 683 try { 684 File repo = new File(FILE_REPOSITORY_PATH); 685 // 686 if (!repo.exists()) { 687 throw new RuntimeException("File repository \"" + repo + "\" does not exist"); 688 } 689 // 690 String _repoPath = repoPath; 691 if (FILE_REPOSITORY_PER_WORKSPACE) { 692 String pathPrefix; 693 if (repoPath.equals("/")) { 694 pathPrefix = _pathPrefix(getWorkspaceId()); 695 _repoPath = pathPrefix; 696 } else { 697 pathPrefix = _pathPrefix(getWorkspaceId(repoPath)); 698 } 699 createWorkspaceFileRepository(new File(repo, pathPrefix)); 700 } 701 // 702 repo = new File(repo, _repoPath); 703 // 704 return checkPath(repo); // throws FileRepositoryException 403 Forbidden 705 } catch (FileRepositoryException e) { 706 throw e; 707 } catch (Exception e) { 708 throw new RuntimeException("Mapping repository path \"" + repoPath + "\" to an absolute path failed", e); 709 } 710 } 711 712 // --- 713 714 /** 715 * Checks if the absolute path represents a directory traversal attack. 716 * If so a FileRepositoryException (403 Forbidden) is thrown. 717 * 718 * @param path The absolute path to check. 719 * 720 * @return The canonized absolute path. 721 */ 722 private File checkPath(File path) throws FileRepositoryException, IOException { 723 // Note: a directory path returned by getCanonicalPath() never contains a "/" at the end. 724 // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well. 725 path = path.getCanonicalFile(); // throws IOException 726 boolean pointsToRepository = path.getPath().startsWith(FILE_REPOSITORY_PATH); 727 // 728 logger.fine("Checking path \"" + path + "\"\n dm4.filerepo.path=" + 729 "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "PATH OK" : "FORBIDDEN")); 730 // 731 if (!pointsToRepository) { 732 throw new FileRepositoryException("\"" + path + "\" does not point to file repository", Status.FORBIDDEN); 733 } 734 // 735 return path; 736 } 737 738 private void checkExistence(File path) throws FileRepositoryException { 739 boolean exists = path.exists(); 740 // 741 logger.fine("Checking existence of \"" + path + "\" => " + (exists ? "EXISTS" : "NOT FOUND")); 742 // 743 if (!exists) { 744 throw new FileRepositoryException("File or directory \"" + path + "\" does not exist", Status.NOT_FOUND); 745 } 746 } 747 748 /** 749 * Checks if the user associated with a request is authorized to access a repository file. 750 * If not authorized a FileRepositoryException (401 Unauthorized) is thrown. 751 * 752 * @param repoPath The repository path of the file to check. Must be canonized. 753 * @param request The request. 754 */ 755 private void checkAuthorization(String repoPath, HttpServletRequest request) throws FileRepositoryException { 756 try { 757 if (FILE_REPOSITORY_PER_WORKSPACE) { 758 // We check authorization for the repository path by checking access to the corresponding File topic. 759 Topic fileTopic = fetchFileTopic(repoPath); 760 if (fileTopic != null) { 761 // We must perform access control for the fetchFileTopic() call manually here. 762 // 763 // Although the AccessControlPlugin's CheckTopicReadAccessListener kicks in, the request is *not* 764 // injected into the AccessControlPlugin letting fetchFileTopic() effectively run as "System". 765 // 766 // Note: checkAuthorization() is called (indirectly) from an OSGi HTTP service static resource 767 // HttpContext. JAX-RS is not involved here. That's why no JAX-RS injection takes place. 768 String username = dm4.getAccessControl().getUsername(request); 769 long fileTopicId = fileTopic.getId(); 770 if (!dm4.getAccessControl().hasPermission(username, Operation.READ, fileTopicId)) { 771 throw new FileRepositoryException(userInfo(username) + " has no READ permission for " + 772 "repository path \"" + repoPath + "\" (File topic ID=" + fileTopicId + ")", 773 Status.UNAUTHORIZED); 774 } 775 } else { 776 throw new RuntimeException("Missing File topic for repository path \"" + repoPath + "\""); 777 } 778 } 779 } catch (FileRepositoryException e) { 780 throw e; 781 } catch (Exception e) { 782 throw new RuntimeException("Checking authorization for repository path \"" + repoPath + "\" failed", e); 783 } 784 } 785 786 private String userInfo(String username) { 787 return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>"); 788 } 789 790 // --- 791 792 /** 793 * Constructs an absolute path from an absolute path and a file name. 794 * 795 * @param directory An absolute path. 796 * 797 * @return The constructed absolute path. 798 */ 799 private File path(File directory, String fileName) { 800 return new File(directory, fileName); 801 } 802 803 /** 804 * Constructs an absolute path for storing an uploaded file. 805 * If a file with that name already exists in the specified directory it remains untouched and the uploaded file 806 * is stored with a unique name (by adding a number). 807 * 808 * @param directory The directory to store the uploaded file to. 809 * A canonized absolute path. 810 * 811 * @return The canonized absolute path. 812 */ 813 private File unusedPath(File directory, UploadedFile file) { 814 return JavaUtils.findUnusedFile(path(directory, file.getName())); 815 } 816 817 // --- 818 819 /** 820 * Returns the repository path of a File/Folder topic. 821 * Note: the returned path is canonized. 822 */ 823 private String repoPath(long fileTopicId) { 824 return repoPath(dm4.getTopic(fileTopicId)); 825 } 826 827 /** 828 * Returns the repository path of a File/Folder topic. 829 * Note: the returned path is canonized. 830 */ 831 private String repoPath(Topic topic) { 832 return topic.getChildTopics().getString("dm4.files.path"); 833 } 834 835 /** 836 * Returns the repository path of a filerepo request. 837 * 838 * @return The repository path or <code>null</code> if the request is not a filerepo request. 839 * Note: the returned path is <i>not</i> canonized. 840 */ 841 private String repoPath(HttpServletRequest request) { 842 String repoPath = null; 843 String requestURI = request.getRequestURI(); 844 if (requestURI.startsWith(FILE_REPOSITORY_URI)) { 845 // Note: the request URI is e.g. /filerepo/%2Fworkspace-1821%2Flogo-escp-europe.gif 846 // +1 cuts off the slash following /filerepo 847 repoPath = requestURI.substring(FILE_REPOSITORY_URI.length() + 1); 848 repoPath = JavaUtils.decodeURIComponent(repoPath); 849 } 850 return repoPath; 851 } 852 853 // --- Per-workspace file repositories --- 854 855 private void createWorkspaceFileRepository(File repo) { 856 try { 857 if (!repo.exists()) { 858 if (repo.mkdir()) { 859 logger.info("### Per-workspace file repository created: \"" + repo + "\""); 860 } else { 861 throw new RuntimeException("Directory \"" + repo + "\" not created successfully"); 862 } 863 } 864 } catch (Exception e) { 865 throw new RuntimeException("Creating per-workspace file repository failed", e); 866 } 867 } 868 869 // --- 870 871 private long getWorkspaceId() { 872 Cookies cookies = Cookies.get(); 873 if (!cookies.has("dm4_workspace_id")) { 874 throw new RuntimeException("If \"dm4.filerepo.per_workspace\" is set the request requires a " + 875 "\"dm4_workspace_id\" cookie"); 876 } 877 return cookies.getLong("dm4_workspace_id"); 878 } 879 880 private long getWorkspaceId(String repoPath) { 881 Matcher m = PER_WORKSPACE_PATH_PATTERN.matcher(repoPath); 882 if (!m.matches()) { 883 throw new RuntimeException("No workspace recognized in repository path \"" + repoPath + "\""); 884 } 885 long workspaceId = Long.parseLong(m.group(1)); 886 return workspaceId; 887 } 888 889 // --- 890 891 private String _pathPrefix(long workspaceId) { 892 return WORKSPACE_DIRECTORY_PREFIX + workspaceId; 893 } 894}