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.StaticResourceFilterListener; 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; 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("dm4.filerepo.path", "/"); 063 public static final boolean FILE_REPOSITORY_PER_WORKSPACE = Boolean.getBoolean("dm4.filerepo.per_workspace"); 064 public static final int DISK_QUOTA_MB = Integer.getInteger("dm4.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 DeepaMehtaEvent CHECK_DISK_QUOTA = new DeepaMehtaEvent(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 + " ABORTED -- 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 + " ABORTED -- 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(), 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 (!DeepaMehtaUtils.isDeepaMehtaURL(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, "dm4.accesscontrol.username", 433 mf.newTopicModel("dm4.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("dm4.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 canonized. 530 */ 531 private Topic fetchFileTopic(String repoPath) { 532 return fetchFileOrFolderTopic(repoPath, "dm4.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 canonized. 540 */ 541 private Topic fetchFolderTopic(String repoPath) { 542 return fetchFileOrFolderTopic(repoPath, "dm4.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 canonized. 552 * @param topicTypeUri The type of the topic to fetch: either "dm4.files.file" or "dm4.files.folder". 553 */ 554 private Topic fetchFileOrFolderTopic(String repoPath, String topicTypeUri) { 555 Topic pathTopic = fetchPathTopic(repoPath); 556 if (pathTopic != null) { 557 return pathTopic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent", topicTypeUri); 558 } 559 return null; 560 } 561 562 /** 563 * @param repoPath A repository path. Must be canonized. 564 */ 565 private Topic fetchPathTopic(String repoPath) { 566 return dm4.getTopicByValue("dm4.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 canonized absolute path. 575 */ 576 private Topic createFileTopic(File path) throws Exception { 577 ChildTopicsModel childTopics = mf.newChildTopicsModel() 578 .put("dm4.files.file_name", path.getName()) 579 .put("dm4.files.path", repoPath(path)) // Note: repo path is already calculated by caller. Could be passed. 580 .put("dm4.files.size", path.length()); 581 // 582 String mediaType = JavaUtils.getFileType(path.getName()); 583 if (mediaType != null) { 584 childTopics.put("dm4.files.media_type", mediaType); 585 } 586 // 587 return createFileOrFolderTopic(mf.newTopicModel("dm4.files.file", childTopics)); // throws Exception 588 } 589 590 /** 591 * Creates a Folder topic representing the directory at the given absolute path. 592 * 593 * @param path A canonized absolute path. 594 */ 595 private Topic createFolderTopic(File path) throws Exception { 596 String folderName = null; 597 String repoPath = repoPath(path); // Note: repo path is already calculated by caller. Could be passed. 598 File repoPathFile = new File(repoPath); 599 // 600 // if the repo path represents a workspace root directory the workspace name is used as Folder Name 601 if (FILE_REPOSITORY_PER_WORKSPACE) { 602 if (repoPathFile.getParent().equals("/")) { 603 String workspaceName = dm4.getTopic(getWorkspaceId(repoPath)).getSimpleValue().toString(); 604 folderName = workspaceName; 605 } 606 } 607 // by default the directory name is used as Folder Name 608 if (folderName == null) { 609 folderName = repoPathFile.getName(); // Note: getName() of "/" returns "" 610 } 611 // 612 return createFileOrFolderTopic(mf.newTopicModel("dm4.files.folder", mf.newChildTopicsModel() 613 .put("dm4.files.folder_name", folderName) 614 .put("dm4.files.path", repoPath))); // throws Exception 615 } 616 617 // --- 618 619 /** 620 * @param repoPath A repository path. Must be canonized. 621 */ 622 private Topic createFileOrFolderTopic(final TopicModel model) throws Exception { 623 // We suppress standard workspace assignment here as File and Folder topics require a special assignment 624 Topic topic = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() { // throws Exception 625 @Override 626 public Topic call() { 627 return dm4.createTopic(model); 628 } 629 }); 630 createWorkspaceAssignment(topic, repoPath(topic)); 631 return topic; 632 } 633 634 /** 635 * @param topic a File topic, or a Folder topic. 636 */ 637 private void createFolderAssociation(final long folderTopicId, Topic topic) { 638 try { 639 final long topicId = topic.getId(); 640 boolean exists = dm4.getAssociations(folderTopicId, topicId, "dm4.core.aggregation").size() > 0; 641 if (!exists) { 642 // We suppress standard workspace assignment as the folder association requires a special assignment 643 Association assoc = dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Association>() { 644 @Override 645 public Association call() { 646 return dm4.createAssociation(mf.newAssociationModel("dm4.core.aggregation", 647 mf.newTopicRoleModel(folderTopicId, "dm4.core.parent"), 648 mf.newTopicRoleModel(topicId, "dm4.core.child") 649 )); 650 } 651 }); 652 createWorkspaceAssignment(assoc, repoPath(topic)); 653 } 654 } catch (Exception e) { 655 throw new RuntimeException("Creating association to Folder topic " + folderTopicId + " failed", e); 656 } 657 } 658 659 // --- 660 661 /** 662 * Creates a workspace assignment for a File topic, a Folder topic, or a folder association (type "Aggregation"). 663 * The workspce is calculated from both, the "dm4.filerepo.per_workspace" flag and the given repository path. 664 * 665 * @param object a File topic, a Folder topic, or a folder association (type "Aggregation"). 666 */ 667 private void createWorkspaceAssignment(DeepaMehtaObject object, String repoPath) { 668 try { 669 AccessControl ac = dm4.getAccessControl(); 670 long workspaceId = FILE_REPOSITORY_PER_WORKSPACE ? getWorkspaceId(repoPath) : ac.getDeepaMehtaWorkspaceId(); 671 ac.assignToWorkspace(object, workspaceId); 672 } catch (Exception e) { 673 throw new RuntimeException("Creating workspace assignment for File/Folder topic or folder association " + 674 "failed", e); 675 } 676 } 677 678 679 680 // === File Repository === 681 682 /** 683 * Maps a repository path to an absolute path. 684 * <p> 685 * Checks the repository path to fight directory traversal attacks. 686 * 687 * @param repoPath A repository path. Relative to the repository base path. 688 * Must begin with slash, no slash at the end. 689 * 690 * @return The canonized absolute path. 691 */ 692 private File absolutePath(String repoPath) throws FileRepositoryException { 693 try { 694 File repo = new File(FILE_REPOSITORY_PATH); 695 // 696 if (!repo.exists()) { 697 throw new RuntimeException("File repository \"" + repo + "\" does not exist"); 698 } 699 // 700 String _repoPath = repoPath; 701 if (FILE_REPOSITORY_PER_WORKSPACE) { 702 String pathPrefix; 703 if (repoPath.equals("/")) { 704 pathPrefix = _pathPrefix(getWorkspaceId()); 705 _repoPath = pathPrefix; 706 } else { 707 pathPrefix = _pathPrefix(getWorkspaceId(repoPath)); 708 } 709 createWorkspaceFileRepository(new File(repo, pathPrefix)); 710 } 711 // 712 repo = new File(repo, _repoPath); 713 // 714 return checkPath(repo); // throws FileRepositoryException 403 Forbidden 715 } catch (FileRepositoryException e) { 716 throw e; 717 } catch (Exception e) { 718 throw new RuntimeException("Mapping repository path \"" + repoPath + "\" to an absolute path failed", e); 719 } 720 } 721 722 // --- 723 724 /** 725 * Checks if the absolute path represents a directory traversal attack. 726 * If so a FileRepositoryException (403 Forbidden) is thrown. 727 * 728 * @param path The absolute path to check. 729 * 730 * @return The canonized absolute path. 731 */ 732 private File checkPath(File path) throws FileRepositoryException, IOException { 733 // Note: a directory path returned by getCanonicalPath() never contains a "/" at the end. 734 // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well. 735 path = path.getCanonicalFile(); // throws IOException 736 boolean pointsToRepository = path.getPath().startsWith(FILE_REPOSITORY_PATH); 737 // 738 logger.fine("Checking path \"" + path + "\"\n dm4.filerepo.path=" + 739 "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "PATH OK" : "FORBIDDEN")); 740 // 741 if (!pointsToRepository) { 742 throw new FileRepositoryException("\"" + path + "\" does not point to file repository", Status.FORBIDDEN); 743 } 744 // 745 return path; 746 } 747 748 private void checkExistence(File path) throws FileRepositoryException { 749 boolean exists = path.exists(); 750 // 751 logger.fine("Checking existence of \"" + path + "\" => " + (exists ? "EXISTS" : "NOT FOUND")); 752 // 753 if (!exists) { 754 throw new FileRepositoryException("File or directory \"" + path + "\" does not exist", Status.NOT_FOUND); 755 } 756 } 757 758 /** 759 * Checks if the user associated with a request is authorized to access a repository file. 760 * If not authorized a FileRepositoryException (401 Unauthorized) is thrown. 761 * 762 * @param repoPath The repository path of the file to check. Must be canonized. 763 * @param request The request. 764 */ 765 private void checkAuthorization(String repoPath, HttpServletRequest request) throws FileRepositoryException { 766 try { 767 if (FILE_REPOSITORY_PER_WORKSPACE) { 768 // We check authorization for the repository path by checking access to the corresponding File topic. 769 Topic fileTopic = fetchFileTopic(repoPath); 770 if (fileTopic != null) { 771 // We must perform access control for the fetchFileTopic() call manually here. 772 // 773 // Although the AccessControlPlugin's CheckTopicReadAccessListener kicks in, the request is *not* 774 // injected into the AccessControlPlugin letting fetchFileTopic() effectively run as "System". 775 // 776 // Note: checkAuthorization() is called (indirectly) from an OSGi HTTP service static resource 777 // HttpContext. JAX-RS is not involved here. That's why no JAX-RS injection takes place. 778 String username = dm4.getAccessControl().getUsername(request); 779 long fileTopicId = fileTopic.getId(); 780 if (!dm4.getAccessControl().hasPermission(username, Operation.READ, fileTopicId)) { 781 throw new FileRepositoryException(userInfo(username) + " has no READ permission for " + 782 "repository path \"" + repoPath + "\" (File topic ID=" + fileTopicId + ")", 783 Status.UNAUTHORIZED); 784 } 785 } else { 786 throw new RuntimeException("Missing File topic for repository path \"" + repoPath + "\""); 787 } 788 } 789 } catch (FileRepositoryException e) { 790 throw e; 791 } catch (Exception e) { 792 throw new RuntimeException("Checking authorization for repository path \"" + repoPath + "\" failed", e); 793 } 794 } 795 796 private String userInfo(String username) { 797 return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>"); 798 } 799 800 // --- 801 802 /** 803 * Constructs an absolute path from an absolute path and a file name. 804 * 805 * @param directory An absolute path. 806 * 807 * @return The constructed absolute path. 808 */ 809 private File path(File directory, String fileName) { 810 return new File(directory, fileName); 811 } 812 813 /** 814 * Constructs an absolute path for storing an uploaded file. 815 * If a file with that name already exists in the specified directory it remains untouched and the uploaded file 816 * is stored with a unique name (by adding a number). 817 * 818 * @param directory The directory to store the uploaded file to. 819 * A canonized absolute path. 820 * 821 * @return The canonized absolute path. 822 */ 823 private File unusedPath(File directory, UploadedFile file) { 824 return JavaUtils.findUnusedFile(path(directory, file.getName())); 825 } 826 827 // --- 828 829 // Note: there is also a public repoPath() method (part of the PathMapper API). 830 // It maps an absolute path to a repository path. 831 832 /** 833 * Returns the repository path of a File/Folder topic. 834 * Note: the returned path is canonized. 835 */ 836 private String repoPath(long fileTopicId) { 837 return repoPath(dm4.getTopic(fileTopicId)); 838 } 839 840 /** 841 * Returns the repository path of a File/Folder topic. 842 * Note: the returned path is canonized. 843 */ 844 private String repoPath(Topic topic) { 845 return topic.getChildTopics().getString("dm4.files.path"); 846 } 847 848 /** 849 * Returns the repository path of a filerepo request. 850 * 851 * @return The repository path or <code>null</code> if the request is not a filerepo request. 852 * Note: the returned path is <i>not</i> canonized. 853 */ 854 private String repoPath(HttpServletRequest request) { 855 String repoPath = null; 856 String requestURI = request.getRequestURI(); 857 if (requestURI.startsWith(FILE_REPOSITORY_URI)) { 858 // Note: the request URI is e.g. /filerepo/%2Fworkspace-1821%2Flogo-escp-europe.gif 859 // +1 cuts off the slash following /filerepo 860 repoPath = requestURI.substring(FILE_REPOSITORY_URI.length() + 1); 861 repoPath = JavaUtils.decodeURIComponent(repoPath); 862 } 863 return repoPath; 864 } 865 866 // --- Per-workspace file repositories --- 867 868 private void createWorkspaceFileRepository(File repo) { 869 try { 870 if (!repo.exists()) { 871 if (repo.mkdir()) { 872 logger.info("### Per-workspace file repository created: \"" + repo + "\""); 873 } else { 874 throw new RuntimeException("Directory \"" + repo + "\" not created successfully"); 875 } 876 } 877 } catch (Exception e) { 878 throw new RuntimeException("Creating per-workspace file repository failed", e); 879 } 880 } 881 882 // --- 883 884 private long getWorkspaceId() { 885 Cookies cookies = Cookies.get(); 886 if (!cookies.has("dm4_workspace_id")) { 887 throw new RuntimeException("If \"dm4.filerepo.per_workspace\" is set the request requires a " + 888 "\"dm4_workspace_id\" cookie"); 889 } 890 return cookies.getLong("dm4_workspace_id"); 891 } 892 893 private long getWorkspaceId(String repoPath) { 894 Matcher m = PER_WORKSPACE_PATH_PATTERN.matcher(repoPath); 895 if (!m.matches()) { 896 throw new RuntimeException("No workspace recognized in repository path \"" + repoPath + "\""); 897 } 898 long workspaceId = Long.parseLong(m.group(1)); 899 return workspaceId; 900 } 901 902 // --- 903 904 private String _pathPrefix(long workspaceId) { 905 return WORKSPACE_DIRECTORY_PREFIX + workspaceId; 906 } 907}