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