diff --git a/files/pom.xml b/files/pom.xml index ad1aabe..fe299b8 100644 --- a/files/pom.xml +++ b/files/pom.xml @@ -44,6 +44,11 @@ org.apache.commons commons-lang3 + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + diff --git a/files/src/main/java/de/nbscloud/files/FileSystemService.java b/files/src/main/java/de/nbscloud/files/FileSystemService.java index 268068e..3e6c6cc 100644 --- a/files/src/main/java/de/nbscloud/files/FileSystemService.java +++ b/files/src/main/java/de/nbscloud/files/FileSystemService.java @@ -127,6 +127,15 @@ public class FileSystemService { } } + public boolean delete(Path path) { + try { + // TODO does only delete dirs if they are empty - but maybe we want that? + return Files.deleteIfExists(path); + } catch (IOException e) { + throw new FileSystemServiceException("Could not delete file", e); + } + } + public byte[] get(Path path) { try { return Files.readAllBytes(path); diff --git a/files/src/main/java/de/nbscloud/files/LocationTracker.java b/files/src/main/java/de/nbscloud/files/LocationTracker.java index cbb2cb2..824d928 100644 --- a/files/src/main/java/de/nbscloud/files/LocationTracker.java +++ b/files/src/main/java/de/nbscloud/files/LocationTracker.java @@ -67,12 +67,30 @@ public class LocationTracker { } public void setCurrentLocation(String navigateTo) { - validate(navigateTo); + validate_internal(navigateTo); this.currentLocation = this.baseDirPath.resolve(navigateTo); } - private void validate(String navigateTo) { + public void validate(String path) { + // Absolut paths are allowed, however, they have to be under baseDir + + if(path == null) { + throw new IllegalStateException("Null"); + } + + if(path.contains("..")) { + throw new IllegalStateException("Relative path"); + } + + final Path tmpPath = Path.of(path); + + if(!tmpPath.normalize().startsWith(this.baseDirPath)) { + throw new IllegalStateException("Illegal path: " + path); + } + } + + private void validate_internal(String navigateTo) { if(navigateTo == null) { throw new IllegalStateException("Null"); } @@ -97,25 +115,25 @@ public class LocationTracker { } public Path resolve(String name) { - validate(name); + validate_internal(name); return this.currentLocation.resolve(name).normalize(); } public Path resolveToBaseDir(String name) { - validate(name); + validate_internal(name); return this.baseDirPath.resolve(name).normalize(); } public Path resolveShare(String shareUuid) { - validate(shareUuid); + validate_internal(shareUuid); return this.baseDirPath.resolve(this.filesConfig.getSharesName()).resolve(shareUuid); } public Path resolveTrash(String name) { - validate(name); + validate_internal(name); return this.baseDirPath.resolve(this.filesConfig.getTrashBinName()).resolve(name); } diff --git a/files/src/main/java/de/nbscloud/files/Share.java b/files/src/main/java/de/nbscloud/files/Share.java new file mode 100644 index 0000000..fca81db --- /dev/null +++ b/files/src/main/java/de/nbscloud/files/Share.java @@ -0,0 +1,42 @@ +package de.nbscloud.files; + +import java.time.LocalDate; + +public class Share { + private String uuid; + private boolean oneTime; + private LocalDate expiryDate; + private String path; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public boolean isOneTime() { + return oneTime; + } + + public void setOneTime(boolean oneTime) { + this.oneTime = oneTime; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(LocalDate expiryDate) { + this.expiryDate = expiryDate; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } +} diff --git a/files/src/main/java/de/nbscloud/files/controller/FilesController.java b/files/src/main/java/de/nbscloud/files/controller/FilesController.java index e68fcfb..228234c 100644 --- a/files/src/main/java/de/nbscloud/files/controller/FilesController.java +++ b/files/src/main/java/de/nbscloud/files/controller/FilesController.java @@ -1,12 +1,17 @@ package de.nbscloud.files.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import de.nbscloud.files.FileSystemService; import de.nbscloud.files.LocationTracker; +import de.nbscloud.files.Share; import de.nbscloud.files.config.FilesConfig; import de.nbscloud.files.exception.FileSystemServiceException; import de.nbscloud.files.form.RenameForm; +import de.nbscloud.files.form.ShareForm; import de.nbscloud.webcontainer.registry.AppRegistry; import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig; +import org.apache.commons.io.input.ObservableInputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -25,15 +30,19 @@ import org.springframework.web.util.UriUtils; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.swing.text.html.Option; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Path; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; @Controller @@ -51,6 +60,8 @@ public class FilesController implements InitializingBean { private FileSystemService fileSystemService; @Autowired private AppRegistry appRegistry; + @Autowired + private ObjectMapper mapper; // We have to temporarily store messages as we redirect: in the manipulation methods // so everything we add to the model will be gone @@ -107,6 +118,7 @@ public class FilesController implements InitializingBean { model.addAttribute("form", new RenameForm(filename, getCurrentLocation(), filename)); model.addAttribute("targetDirs", this.fileSystemService.collectDirs(filename)); model.addAttribute("apps", this.appRegistry.getAll()); + model.addAttribute("filename", filename); this.webContainerSharedConfig.addDefaults(model); return "files/rename"; @@ -135,7 +147,7 @@ public class FilesController implements InitializingBean { } @GetMapping("/files/download") - public ResponseEntity downloadFile(String filename) { + public ResponseEntity downloadFile(String filename) { // TODO download of directories via .zip? Also needs to be streaming try { @@ -147,7 +159,7 @@ public class FilesController implements InitializingBean { } catch (RuntimeException e) { logger.error("Could not get file", e); - return ResponseEntity.internalServerError().body(null); + return ResponseEntity.internalServerError().body(":("); } } @@ -183,41 +195,82 @@ public class FilesController implements InitializingBean { return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); } - @GetMapping("/files/share") - public String share(String filename) { + @GetMapping("files/share") + public String share(Model model, String filename) { + model.addAttribute("currentLocation", getCurrentLocationPrefixed()); + model.addAttribute("form", new ShareForm(filename, false, LocalDate.now().plusDays(1))); + model.addAttribute("apps", this.appRegistry.getAll()); + model.addAttribute("filename", filename); + this.webContainerSharedConfig.addDefaults(model); + + return "files/share"; + } + + @PostMapping("/files/doShare") + public String doShare(@RequestParam("filename") String filename, + @RequestParam(value = "oneTime", defaultValue = "false") boolean oneTime, + @RequestParam(value = "expiryDate", required = false) String expiryDateString + ) { final Path filePath = this.locationTracker.getRelativeToBaseDir(this.locationTracker.resolve(filename)); final String shareUuid = UUID.randomUUID().toString(); + final Share share = new Share(); + // The format is always "yyyy-MM-dd", see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date + final LocalDate expiryDate = Optional.ofNullable(expiryDateString) + .map(ed -> LocalDate.parse(ed, DateTimeFormatter.ofPattern("yyyy-MM-dd"))) + .orElse(null); + + share.setUuid(shareUuid); + share.setPath(filePath.toString()); + share.setExpiryDate(expiryDate); + share.setOneTime(oneTime); try { - this.fileSystemService.createFile(this.locationTracker.resolveShare(shareUuid), filePath.toString().getBytes(StandardCharsets.UTF_8)); + final String shareJson = mapper.writeValueAsString(share); - this.shareInfo.add("/files/shares?shareUuid=" + shareUuid); - } catch (RuntimeException e) { + this.fileSystemService.createFile(this.locationTracker.resolveShare(share.getUuid()), shareJson.getBytes(StandardCharsets.UTF_8)); + + this.shareInfo.add("/files/shares?shareUuid=" + share.getUuid()); + } catch (RuntimeException | JsonProcessingException e) { logger.error("Could not share file", e); this.errors.add(e.getMessage()); } - return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); } @GetMapping("files/shares") public ResponseEntity shares(String shareUuid) { try { - final String sharedFile = new String(this.fileSystemService.get(this.locationTracker.resolveShare(shareUuid))); - final Path sharedFilePath = this.locationTracker.resolveToBaseDir(sharedFile); + final String shareJson = new String(this.fileSystemService.get(this.locationTracker.resolveShare(shareUuid))); + final Share share = mapper.readValue(shareJson, Share.class); + final Path sharedFilePath = this.locationTracker.resolveToBaseDir(share.getPath()); final String filename = sharedFilePath.getFileName().toString(); + final Optional optExpiryDate = Optional.ofNullable(share.getExpiryDate()); + final boolean expired = optExpiryDate.map(expiryDate -> LocalDate.now().isAfter(expiryDate)).orElse(false); + + if (expired) { + fileSystemService.delete(locationTracker.resolveShare(shareUuid)); + + return ResponseEntity.status(410).body("Share expired!"); + } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") .header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename)) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename))) - .body(new InputStreamResource(this.fileSystemService.stream(filename))); - } catch (RuntimeException e) { + .body(new InputStreamResource(new ObservableInputStream(this.fileSystemService.stream(filename), new ObservableInputStream.Observer() { + @Override + public void closed() throws IOException { + if (share.isOneTime()) { + fileSystemService.delete(locationTracker.resolveShare(shareUuid)); + } + } + }))); + } catch (RuntimeException | JsonProcessingException e) { logger.error("Could not get shared file", e); - return ResponseEntity.internalServerError().body(null); + return ResponseEntity.internalServerError().body(":("); } } diff --git a/files/src/main/java/de/nbscloud/files/form/ShareForm.java b/files/src/main/java/de/nbscloud/files/form/ShareForm.java new file mode 100644 index 0000000..25cf532 --- /dev/null +++ b/files/src/main/java/de/nbscloud/files/form/ShareForm.java @@ -0,0 +1,39 @@ +package de.nbscloud.files.form; + +import java.time.LocalDate; + +public class ShareForm { + private String filename; + private boolean oneTime; + private LocalDate expiryDate; + + public ShareForm(String filename, boolean oneTime, LocalDate expiryDate) { + this.filename = filename; + this.oneTime = oneTime; + this.expiryDate = expiryDate; + } + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public boolean isOneTime() { + return oneTime; + } + + public void setOneTime(boolean oneTime) { + this.oneTime = oneTime; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(LocalDate expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/files/src/main/resources/i18n/files_messages.properties b/files/src/main/resources/i18n/files_messages.properties index 9247cbf..6fd8be1 100644 --- a/files/src/main/resources/i18n/files_messages.properties +++ b/files/src/main/resources/i18n/files_messages.properties @@ -6,18 +6,22 @@ nbscloud.files.files-content-table.table-header.lastmodified=Last modified nbscloud.files.files-content-table.table-header.actions=Actions nbscloud.files-content-table.table.actions.delete=Delete -nbscloud.files-content-table.table.actions.rename=Rename/move +nbscloud.files-content-table.table.actions.rename=Rename/move... nbscloud.files-content-table.table.actions.download=Download -nbscloud.files-content-table.table.actions.share=Share +nbscloud.files-content-table.table.actions.share=Share... nbscloud.files-content-table.table.actions.preview=Preview nbscloud.files.rename.title=nbscloud - files\: rename/move \u0020 - nbscloud.files.rename.label.filename=Filename\: nbscloud.files.rename.label.targetDir=Target directory\: - nbscloud.files.rename.submit=Rename/move +nbscloud.files.share.title=nbscloud - files\: share \u0020 +nbscloud.files.share.label.filename=Filename\: +nbscloud.files.share.label.onetime=One time\: +nbscloud.files.share.label.expirydate=Expiry date\: +nbscloud.files.share.submit=Share + nbscloud.files.share-message=Shared file\:\u0020 nbscloud.files.delete.success=File deleted diff --git a/files/src/main/resources/i18n/files_messages_de_DE.properties b/files/src/main/resources/i18n/files_messages_de_DE.properties index a0f2eb6..e9d5f70 100644 --- a/files/src/main/resources/i18n/files_messages_de_DE.properties +++ b/files/src/main/resources/i18n/files_messages_de_DE.properties @@ -6,18 +6,22 @@ nbscloud.files.files-content-table.table-header.lastmodified=Zuletzt ge\u00E4nde nbscloud.files.files-content-table.table-header.actions=Aktionen nbscloud.files-content-table.table.actions.delete=L\u00F6schen -nbscloud.files-content-table.table.actions.rename=Umbenennen/verschieben +nbscloud.files-content-table.table.actions.rename=Umbenennen/verschieben... nbscloud.files-content-table.table.actions.download=Download -nbscloud.files-content-table.table.actions.share=Teilen +nbscloud.files-content-table.table.actions.share=Teilen... nbscloud.files-content-table.table.actions.preview=Vorschau nbscloud.files.rename.title=nbscloud - Dateien\: umbenennen/verschieben \u0020 - nbscloud.files.rename.label.filename=Dateiname\: nbscloud.files.rename.label.targetDir=Zielverzeichnis\: - nbscloud.files.rename.submit=Umbenennen/verschieben +nbscloud.files.share.title=nbscloud - Dateien\: teilen \u0020 +nbscloud.files.share.label.filename=Dateiname\: +nbscloud.files.share.label.onetime=Einmaliger Abruf\: +nbscloud.files.share.label.expirydate=Ablaufdatum\: +nbscloud.files.share.submit=Teilen + nbscloud.files.share-message=Datei geteilt\:\u0020 nbscloud.files.delete.success=Datei gel\u00F6scht diff --git a/files/src/main/resources/static/css/files_main.css b/files/src/main/resources/static/css/files_main.css index f563cb5..e85e71f 100644 --- a/files/src/main/resources/static/css/files_main.css +++ b/files/src/main/resources/static/css/files_main.css @@ -48,17 +48,19 @@ cursor: pointer; } -#rename-form { +#rename-form, +#share-form { margin-top: 3em; margin-left: 3em; } -#rename-form * { +#rename-form *, +#share-form * { display: block; margin-top: 1em; box-sizing: border-box; } -#rename-form > select > * { +#rename-form > select > *{ margin-top: unset; } @@ -66,7 +68,8 @@ width: 70em; } -#rename-form > input[type=text] { +#rename-form > input[type=text], +#share-form > input[type=text] { width: 70em; } diff --git a/files/src/main/resources/templates/files/share.html b/files/src/main/resources/templates/files/share.html new file mode 100644 index 0000000..fbfe0ab --- /dev/null +++ b/files/src/main/resources/templates/files/share.html @@ -0,0 +1,28 @@ + + + + + + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/> + <link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/> + <link rel="stylesheet" th:href="@{/css/main.css}"/> + <link rel="stylesheet" th:href="@{/css/files_main.css}"/> + <link rel="shortcut icon" th:href="@{/favicon.ico}"/> +</head> +<body> +<div id="main-container"> + <div th:replace="includes/header :: header"/> + <form id="share-form" action="#" th:action="@{/files/doShare}" th:object="${form}" method="post" enctype="multipart/form-data"> + <label for="filename" th:text="#{nbscloud.files.share.label.filename}"/> + <input type="text" id="filename" th:field="*{filename}" readonly /> + <label for="oneTime" th:text="#{nbscloud.files.share.label.onetime}"/> + <input type="checkbox" id="oneTime" th:field="*{oneTime}" /> + <label for="expiryDate" th:text="#{nbscloud.files.share.label.expirydate}"/> + <input type="date" id="expiryDate" th:field="*{expiryDate}"/> + <input type="submit" th:value="#{nbscloud.files.share.submit}" /> + </form> + <div th:replace="includes/footer :: footer"/> +</div> +</body> +</html> \ No newline at end of file