001 package de.deepamehta.plugins.files; 002 003 import de.deepamehta.plugins.files.service.FilesService; 004 005 import de.deepamehta.core.Topic; 006 import de.deepamehta.core.model.AssociationModel; 007 import de.deepamehta.core.model.CompositeValueModel; 008 import de.deepamehta.core.model.SimpleValue; 009 import de.deepamehta.core.model.TopicModel; 010 import de.deepamehta.core.model.TopicRoleModel; 011 import de.deepamehta.core.osgi.PluginActivator; 012 import de.deepamehta.core.service.ClientState; 013 import de.deepamehta.core.service.SecurityHandler; 014 import de.deepamehta.core.util.DeepaMehtaUtils; 015 import de.deepamehta.core.util.JavaUtils; 016 017 import javax.ws.rs.Consumes; 018 import javax.ws.rs.GET; 019 import javax.ws.rs.HeaderParam; 020 import javax.ws.rs.Path; 021 import javax.ws.rs.PathParam; 022 import javax.ws.rs.POST; 023 import javax.ws.rs.Produces; 024 import javax.ws.rs.WebApplicationException; 025 import javax.ws.rs.core.Response.Status; 026 027 import javax.servlet.http.HttpServletRequest; 028 import javax.servlet.http.HttpServletResponse; 029 030 import java.awt.Desktop; 031 import java.io.File; 032 import java.net.URL; 033 import java.util.logging.Logger; 034 035 036 037 @Path("/files") 038 @Produces("application/json") 039 public class FilesPlugin extends PluginActivator implements FilesService, SecurityHandler { 040 041 // ------------------------------------------------------------------------------------------------------- Constants 042 043 private static final String FILE_REPOSITORY_PATH = System.getProperty("dm4.filerepo.path"); 044 private static final String FILE_REPOSITORY_URI = "/filerepo"; 045 046 // ---------------------------------------------------------------------------------------------- Instance Variables 047 048 private Logger logger = Logger.getLogger(getClass().getName()); 049 050 // -------------------------------------------------------------------------------------------------- Public Methods 051 052 053 054 // *********************************** 055 // *** FilesService Implementation *** 056 // *********************************** 057 058 059 060 // === File System Representation === 061 062 @POST 063 @Path("/file/{path:.+}") // Note: we also match slashes as they are already decoded by an apache reverse proxy 064 @Override 065 public Topic createFileTopic(@PathParam("path") String path, @HeaderParam("Cookie") ClientState clientState) { 066 String operation = "Creating file topic for repository path \"" + path + "\""; 067 try { 068 logger.info(operation); 069 // ### FIXME: drag'n'drop files from arbitrary locations (in particular different Windows drives) 070 // collides with the concept of a single-rooted file repository (as realized by the Files module). 071 // For the moment we just strip a possible drive letter to be compatible with the Files module. 072 path = JavaUtils.stripDriveLetter(path); 073 // 074 // 1) enforce security 075 File file = enforeSecurity(path); 076 // 077 // 2) check if topic already exists 078 Topic fileTopic = fetchFileTopic(file); 079 if (fileTopic != null) { 080 logger.info(operation + " ABORTED -- already exists"); 081 return fileTopic; 082 } 083 // 3) create topic 084 return createFileTopic(file, clientState); 085 } catch (FileRepositoryException e) { 086 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 087 } catch (Exception e) { 088 throw new RuntimeException(operation + " failed", e); 089 } 090 } 091 092 @POST 093 @Path("/folder/{path:.+}") // Note: we also match slashes as they are already decoded by an apache reverse proxy 094 @Override 095 public Topic createFolderTopic(@PathParam("path") String path, @HeaderParam("Cookie") ClientState clientState) { 096 String operation = "Creating folder topic for repository path \"" + path + "\""; 097 try { 098 logger.info(operation); 099 // ### FIXME: drag'n'drop folders from arbitrary locations (in particular different Windows drives) 100 // collides with the concept of a single-rooted file repository (as realized by the Files module). 101 // For the moment we just strip a possible drive letter to be compatible with the Files module. 102 path = JavaUtils.stripDriveLetter(path); 103 // 104 // 1) enforce security 105 File file = enforeSecurity(path); 106 // 107 // 2) check if topic already exists 108 Topic folderTopic = fetchFolderTopic(file); 109 if (folderTopic != null) { 110 logger.info(operation + " ABORTED -- already exists"); 111 return folderTopic; 112 } 113 // 3) create topic 114 return createFolderTopic(file, clientState); 115 } catch (FileRepositoryException e) { 116 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 117 } catch (Exception e) { 118 throw new RuntimeException(operation + " failed", e); 119 } 120 } 121 122 // --- 123 124 @POST 125 @Path("/parent/{id}/file/{path:.+}") // Note: we also match slashes as they are already decoded by an apache ... 126 @Override 127 public Topic createChildFileTopic(@PathParam("id") long folderTopicId, @PathParam("path") String path, 128 @HeaderParam("Cookie") ClientState clientState) { 129 Topic childTopic = createFileTopic(path, clientState); 130 associateChildTopic(folderTopicId, childTopic.getId()); 131 return childTopic; 132 } 133 134 @POST 135 @Path("/parent/{id}/folder/{path:.+}") // Note: we also match slashes as they are already decoded by an apache ... 136 @Override 137 public Topic createChildFolderTopic(@PathParam("id") long folderTopicId, @PathParam("path") String path, 138 @HeaderParam("Cookie") ClientState clientState) { 139 Topic childTopic = createFolderTopic(path, clientState); 140 associateChildTopic(folderTopicId, childTopic.getId()); 141 return childTopic; 142 } 143 144 145 146 // === File Repository === 147 148 @POST 149 @Path("/{path:.+}") // Note: we also match slashes as they are already decoded by an apache reverse proxy 150 @Consumes("multipart/form-data") 151 @Override 152 public StoredFile storeFile(UploadedFile file, @PathParam("path") String path, 153 @HeaderParam("Cookie") ClientState clientState) { 154 String operation = "Storing " + file + " at repository path \"" + path + "\""; 155 try { 156 logger.info(operation); 157 // 1) enforce security 158 File directory = enforeSecurity(path); 159 // 160 // 2) store file 161 File repoFile = repoFile(directory, file); 162 file.write(repoFile); 163 // 164 // 3) create topic 165 Topic fileTopic = createFileTopic(repoFile, clientState); 166 return new StoredFile(repoFile.getName(), fileTopic.getId()); 167 } catch (FileRepositoryException e) { 168 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 169 } catch (Exception e) { 170 throw new RuntimeException(operation + " failed", e); 171 } 172 } 173 174 @POST 175 @Path("/{path:.+}/folder/{folder_name}") // Note: we also match slashes as they are already decoded by an apache ... 176 @Override 177 public void createFolder(@PathParam("folder_name") String folderName, @PathParam("path") String path) { 178 String operation = "Creating folder \"" + folderName + "\" at repository path \"" + path + "\""; 179 try { 180 logger.info(operation); 181 // 1) enforce security 182 File directory = enforeSecurity(path); 183 // 184 // 2) create directory 185 File repoFile = repoFile(directory, folderName); 186 if (repoFile.exists()) { 187 throw new RuntimeException("File or directory \"" + repoFile + "\" already exists"); 188 } 189 // 190 boolean success = repoFile.mkdir(); 191 // 192 if (!success) { 193 throw new RuntimeException("File.mkdir() failed (file=\"" + repoFile + "\")"); 194 } 195 } catch (FileRepositoryException e) { 196 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 197 } catch (Exception e) { 198 throw new RuntimeException(operation + " failed", e); 199 } 200 } 201 202 // --- 203 204 @GET 205 @Path("/{path:.+}/info") // Note: we also match slashes as they are already decoded by an apache reverse proxy 206 @Override 207 public ResourceInfo getResourceInfo(@PathParam("path") String path) { 208 String operation = "Getting resource info for repository path \"" + path + "\""; 209 try { 210 logger.info(operation); 211 // 212 File file = enforeSecurity(path); 213 // 214 return new ResourceInfo(file); 215 } catch (FileRepositoryException e) { 216 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 217 } catch (Exception e) { 218 throw new RuntimeException(operation + " failed", e); 219 } 220 } 221 222 @GET 223 @Path("/{path:.+}") // Note: we also match slashes as they are already decoded by an apache reverse proxy 224 @Override 225 public DirectoryListing getDirectoryListing(@PathParam("path") String path) { 226 String operation = "Getting directory listing for repository path \"" + path + "\""; 227 try { 228 logger.info(operation); 229 // 230 File folder = enforeSecurity(path); 231 // 232 return new DirectoryListing(folder); // ### TODO: if folder is no directory send NOT FOUND 233 } catch (FileRepositoryException e) { 234 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 235 } catch (Exception e) { 236 throw new RuntimeException(operation + " failed", e); 237 } 238 } 239 240 @Override 241 public String getRepositoryPath(URL url) { 242 String operation = "Checking for file repository URL (\"" + url + "\")"; 243 try { 244 if (!DeepaMehtaUtils.isDeepaMehtaURL(url)) { 245 logger.info(operation + " => null"); 246 return null; 247 } 248 // 249 String path = url.getPath(); 250 if (!path.startsWith(FILE_REPOSITORY_URI)) { 251 logger.info(operation + " => null"); 252 return null; 253 } 254 // 255 String repoPath = path.substring(FILE_REPOSITORY_URI.length()); 256 logger.info(operation + " => \"" + repoPath + "\""); 257 return repoPath; 258 } catch (Exception e) { 259 throw new RuntimeException(operation + " failed", e); 260 } 261 } 262 263 // --- 264 265 // Note: this is not a resource method. 266 // To access a file remotely use the /filerepo resource. 267 @Override 268 public File getFile(String path) { 269 String operation = "Accessing the file at \"" + path + "\""; 270 try { 271 logger.info(operation); 272 // 273 File file = enforeSecurity(path); 274 return file; 275 } catch (Exception e) { 276 throw new RuntimeException(operation + " failed", e); 277 } 278 } 279 280 // Note: this is not a resource method. 281 // To access a file remotely use the /filerepo resource. 282 @Override 283 public File getFile(long fileTopicId) { 284 String operation = "Accessing the file of file topic " + fileTopicId; 285 try { 286 logger.info(operation); 287 // 288 String path = repoPath(fileTopicId); 289 File file = enforeSecurity(path); 290 return file; 291 } catch (Exception e) { 292 throw new RuntimeException(operation + " failed", e); 293 } 294 } 295 296 // --- 297 298 @POST 299 @Path("/open/{id}") 300 @Override 301 public void openFile(@PathParam("id") long fileTopicId) { 302 String operation = "Opening the file of file topic " + fileTopicId; 303 try { 304 logger.info(operation); 305 // 306 String path = repoPath(fileTopicId); 307 File file = enforeSecurity(path); 308 // 309 logger.info("### Opening file \"" + file + "\""); 310 Desktop.getDesktop().open(file); 311 } catch (FileRepositoryException e) { 312 throw new WebApplicationException(new RuntimeException(operation + " failed", e), e.getStatus()); 313 } catch (Exception e) { 314 throw new RuntimeException(operation + " failed", e); 315 } 316 } 317 318 319 320 // ************************************** 321 // *** SecurityHandler Implementation *** 322 // ************************************** 323 324 325 326 // ### TODO: to be dropped? 327 @Override 328 public boolean handleSecurity(HttpServletRequest request, HttpServletResponse response) { 329 try { 330 String path = request.getRequestURI().substring(FILE_REPOSITORY_URI.length()); 331 path = JavaUtils.decodeURIComponent(path); 332 logger.info("### repository path=\"" + path + "\""); 333 enforeSecurity(path); 334 return true; 335 } catch (FileRepositoryException e) { 336 response.setStatus(e.getStatusCode()); 337 return false; 338 } catch (Exception e) { 339 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 340 return false; 341 } 342 } 343 344 345 346 // **************************** 347 // *** Hook Implementations *** 348 // **************************** 349 350 351 352 @Override 353 public void init() { 354 publishDirectory(FILE_REPOSITORY_PATH, FILE_REPOSITORY_URI, this); // securityHandler=this 355 } 356 357 // ------------------------------------------------------------------------------------------------- Private Methods 358 359 360 361 // === File System Representation === 362 363 private Topic fetchFileTopic(File file) { 364 String path = repoPath(file); 365 Topic topic = dms.getTopic("dm4.files.path", new SimpleValue(path), false); // fetchComposite=false 366 if (topic != null) { 367 return topic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent", 368 "dm4.files.file", true, false); 369 } 370 return null; 371 } 372 373 private Topic fetchFolderTopic(File file) { 374 String path = repoPath(file); 375 Topic topic = dms.getTopic("dm4.files.path", new SimpleValue(path), false); // fetchComposite=false 376 if (topic != null) { 377 return topic.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent", 378 "dm4.files.folder", true, false); 379 } 380 return null; 381 } 382 383 // --- 384 385 private Topic createFileTopic(File file, ClientState clientState) { 386 String mediaType = JavaUtils.getFileType(file.getName()); 387 // 388 CompositeValueModel comp = new CompositeValueModel(); 389 comp.put("dm4.files.file_name", file.getName()); 390 comp.put("dm4.files.path", repoPath(file)); 391 if (mediaType != null) { 392 comp.put("dm4.files.media_type", mediaType); 393 } 394 comp.put("dm4.files.size", file.length()); 395 // 396 return dms.createTopic(new TopicModel("dm4.files.file", comp), clientState); 397 } 398 399 private Topic createFolderTopic(File file, ClientState clientState) { 400 String folderName = file.getName(); 401 String path = repoPath(file); 402 // 403 // root folder needs special treatment 404 if (path.equals("/")) { 405 folderName = ""; 406 } 407 // 408 CompositeValueModel comp = new CompositeValueModel(); 409 comp.put("dm4.files.folder_name", folderName); 410 comp.put("dm4.files.path", path); 411 // 412 return dms.createTopic(new TopicModel("dm4.files.folder", comp), clientState); 413 } 414 415 // --- 416 417 private void associateChildTopic(long folderTopicId, long childTopicId) { 418 if (!childAssociationExists(folderTopicId, childTopicId)) { 419 dms.createAssociation(new AssociationModel("dm4.core.aggregation", 420 new TopicRoleModel(folderTopicId, "dm4.core.parent"), 421 new TopicRoleModel(childTopicId, "dm4.core.child")), null); // clientState=null 422 } 423 } 424 425 private boolean childAssociationExists(long folderTopicId, long childTopicId) { 426 return dms.getAssociations(folderTopicId, childTopicId, "dm4.core.aggregation").size() > 0; 427 } 428 429 430 431 // === File Repository === 432 433 /** 434 * Maps a repository path to a repository file. 435 */ 436 private File repoFile(String path) { 437 try { 438 File file = new File(FILE_REPOSITORY_PATH, path); 439 // Note 1: we use getCanonicalPath() to fight directory traversal attacks (../../). 440 // Note 2: A directory path returned by getCanonicalPath() never contains a "/" at the end. 441 // Thats why "dm4.filerepo.path" is expected to have no "/" at the end as well. 442 return file.getCanonicalFile(); // throws IOException 443 } catch (Exception e) { 444 throw new RuntimeException("Mapping repository path \"" + path + "\" to repository file failed", e); 445 } 446 } 447 448 /** 449 * Calculates the storage location for the uploaded file. 450 */ 451 private File repoFile(File directory, UploadedFile file) { 452 return JavaUtils.findUnusedFile(repoFile(directory, file.getName())); 453 } 454 455 private File repoFile(File directory, String fileName) { 456 return new File(directory, fileName); 457 } 458 459 // --- 460 461 /** 462 * Maps a repository file to a repository path. 463 */ 464 private String repoPath(File file) { 465 String path = file.getPath().substring(FILE_REPOSITORY_PATH.length()); 466 // root folder needs special treatment 467 if (path.equals("")) { 468 path = "/"; 469 } 470 // 471 return path; 472 // ### TODO: there is a principle copy in DirectoryListing 473 // ### FIXME: Windows drive letter? See DirectoryListing 474 } 475 476 private String repoPath(long fileTopicId) { 477 Topic fileTopic = dms.getTopic(fileTopicId, true); // fetchComposite=true 478 return fileTopic.getCompositeValue().getString("dm4.files.path"); 479 } 480 481 482 483 // === Security === 484 485 private File enforeSecurity(String path) throws FileRepositoryException { 486 File file = repoFile(path); 487 checkFilePath(file); 488 checkFileExistence(file); 489 // 490 return file; 491 } 492 493 // --- File Access --- 494 495 /** 496 * Prerequisite: the file's path is canonical. 497 */ 498 private void checkFilePath(File file) throws FileRepositoryException { 499 boolean pointsToRepository = file.getPath().startsWith(FILE_REPOSITORY_PATH); 500 // 501 logger.info("Checking file repository access to \"" + file + "\"\n dm4.filerepo.path=" + 502 "\"" + FILE_REPOSITORY_PATH + "\" => " + (pointsToRepository ? "ALLOWED" : "FORBIDDEN")); 503 // 504 if (!pointsToRepository) { 505 throw new FileRepositoryException("\"" + file + "\" is not a file repository path", Status.FORBIDDEN); 506 } 507 } 508 509 private void checkFileExistence(File file) throws FileRepositoryException { 510 if (!file.exists()) { 511 logger.info("File or directory \"" + file + "\" does not exist => NOT FOUND"); 512 throw new FileRepositoryException("\"" + file + "\" does not exist in file repository", Status.NOT_FOUND); 513 } 514 } 515 }