diff --git a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 46834d0..25cfd07 100644 --- a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java @@ -34,7 +34,7 @@ public class SecurityConfig { if (applicationSettingsService.isAppPasswordEnabled()) { http .authorizeHttpRequests(authz -> authz - .requestMatchers("/password/login", "/favicon.ico", "/error").permitAll() + .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index d51029c..4a32270 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -1,14 +1,17 @@ package org.rostislav.quickdrop.controller; +import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.service.FileService; +import org.rostislav.quickdrop.util.FileUtils; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.time.LocalDate; @RestController @RequestMapping("/api/file") @@ -32,4 +35,45 @@ public class FileRestController { return ResponseEntity.badRequest().build(); } } + + @PostMapping("/share/{id}") + public ResponseEntity generateShareableLink(@PathVariable Long id, HttpServletRequest request) { + FileEntity fileEntity = fileService.getFile(id); + if (fileEntity == null) { + return ResponseEntity.badRequest().body("File not found."); + } + + String password = (String) request.getSession().getAttribute("password"); + if (fileEntity.passwordHash != null) { + if (password == null || !fileService.checkPassword(fileEntity.uuid, password)) { + System.out.println("Invalid or missing password."); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid or missing password in session."); + } + } + + String token = fileService.generateShareToken(id, LocalDate.now().plusDays(30)); + String shareLink = FileUtils.getShareLink(request, fileEntity, token); + return ResponseEntity.ok(shareLink); + } + + @GetMapping("/download/{uuid}/{token}") + public ResponseEntity downloadFile(@PathVariable String uuid, @PathVariable String token) { + try { + StreamingResponseBody responseBody = fileService.streamFileAndInvalidateToken(uuid, token); + if (responseBody == null) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + FileEntity fileEntity = fileService.getFile(uuid); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + fileEntity.name + "\"") + .header("Content-Length", String.valueOf(fileEntity.size)) + .body(responseBody); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 64a81ff..7e53119 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -178,4 +178,17 @@ public class FileViewController { return "file-password"; } } + + @GetMapping("/share/{uuid}/{token}") + public String viewSharedFile(@PathVariable String uuid, @PathVariable String token, Model model) { + if (!fileService.validateShareToken(uuid, token)) { + return "invalid-share-link"; + } + + FileEntity file = fileService.getFile(uuid); + model.addAttribute("file", new FileEntityView(file, analyticsService.getTotalDownloadsByFile(file.id))); + model.addAttribute("downloadLink", "/api/file/download/" + file.uuid + "/" + token); + + return "file-share-view"; + } } diff --git a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java index 2c55bbb..835c4b4 100644 --- a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java @@ -19,6 +19,11 @@ public class FileEntity { public String passwordHash; @Column(columnDefinition = "boolean default false") public boolean hidden; + @Column(nullable = true) + public String shareToken; + + @Column(nullable = true) + public LocalDate tokenExpirationDate; @PrePersist public void prePersist() { diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index acb2e3c..3fd7e7b 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -22,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.io.File; import java.io.FileInputStream; +import java.io.OutputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -312,4 +313,72 @@ public class FileService { return fileRepository.findAllFilesWithDownloadCounts(); } + public String generateShareToken(Long fileId, LocalDate tokenExpirationDate) { + Optional optionalFile = fileRepository.findById(fileId); + if (optionalFile.isEmpty()) { + throw new IllegalArgumentException("File not found"); + } + + FileEntity file = optionalFile.get(); + String token = UUID.randomUUID().toString(); // Generate a unique token + file.shareToken = token; + file.tokenExpirationDate = tokenExpirationDate; + fileRepository.save(file); + + logger.info("Share token generated for file: {}", file.name); + return token; + } + + public boolean validateShareToken(String uuid, String token) { + Optional optionalFile = fileRepository.findByUUID(uuid); + if (optionalFile.isEmpty()) { + return false; + } + + FileEntity file = optionalFile.get(); + if (!token.equals(file.shareToken)) { + return false; + } + + return file.tokenExpirationDate == null || !LocalDate.now().isAfter(file.tokenExpirationDate); + } + + private void writeFileToStream(String uuid, OutputStream outputStream) { + Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid); + try (FileInputStream inputStream = new FileInputStream(path.toFile())) { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } catch ( + Exception e) { + logger.error("Error writing file to stream: {}", e.getMessage()); + } + } + + public StreamingResponseBody streamFileAndInvalidateToken(String uuid, String token) { + Optional optionalFile = fileRepository.findByUUID(uuid); + + if (optionalFile.isEmpty() || !validateShareToken(uuid, token)) { + return null; + } + + FileEntity fileEntity = optionalFile.get(); + + return outputStream -> { + try { + writeFileToStream(uuid, outputStream); + + fileEntity.shareToken = null; + fileEntity.tokenExpirationDate = null; + fileRepository.save(fileEntity); + + logger.info("Share token invalidated and file streamed successfully: {}", fileEntity.name); + } catch (Exception e) { + logger.error("Error streaming file or invalidating token for UUID: {}", uuid, e); + } + }; + } } diff --git a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java index f0b99c9..96fe09c 100644 --- a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java +++ b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java @@ -32,6 +32,10 @@ public class FileUtils { return request.getScheme() + "://" + request.getServerName() + "/file/" + fileEntity.uuid; } + public static String getShareLink(HttpServletRequest request, FileEntity fileEntity, String token) { + return request.getScheme() + "://" + request.getServerName() + "/file/share/" + fileEntity.uuid + "/" + token; + } + public static void populateModelAttributes(FileEntity fileEntity, Model model, HttpServletRequest request) { model.addAttribute("file", fileEntity); model.addAttribute("fileSize", formatFileSize(fileEntity.size)); diff --git a/src/main/resources/static/js/fileView.js b/src/main/resources/static/js/fileView.js index 1feacf5..2766829 100644 --- a/src/main/resources/static/js/fileView.js +++ b/src/main/resources/static/js/fileView.js @@ -32,8 +32,6 @@ document.addEventListener("DOMContentLoaded", function () { const downloadLink = document.getElementById("downloadLink").value; // Get the file download link const qrCodeContainer = document.getElementById("qrCodeContainer"); // Container for the QR code - console.log("Download link:", downloadLink); // Debugging log - if (downloadLink) { QRCode.toCanvas(qrCodeContainer, encodeURI(downloadLink), { width: 100, // Size of the QR Code @@ -61,4 +59,62 @@ function updateCheckboxState(event, checkbox) { console.log('Submitting form...'); checkbox.form.submit(); +} + +function openShareModal() { + const fileId = document.getElementById("fileId").textContent.trim(); + const filePasswordInput = document.getElementById("filePassword"); + const password = filePasswordInput ? filePasswordInput.value : ""; + + generateShareLink(fileId, password) + .then(link => { + const shareLinkInput = document.getElementById("shareLink"); + shareLinkInput.value = link; + + // Generate QR code for the share link + const shareQRCode = document.getElementById("shareQRCode"); + QRCode.toCanvas(shareQRCode, encodeURI(link), { + width: 150, + margin: 2 + }, function (error) { + if (error) { + console.error("QR Code generation failed:", error); + } + }); + + // Show the modal + const shareModal = new bootstrap.Modal(document.getElementById('shareModal')); + shareModal.show(); + }) + .catch(error => { + console.error(error); + alert("Error generating share link."); + }); +} + + +function generateShareLink(fileId) { + const csrfToken = document.querySelector('meta[name="_csrf"]').content; // Retrieve CSRF token + return fetch(`/api/file/share/${fileId}`, { + method: 'POST', + credentials: 'same-origin', // Ensures cookies are sent for session + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': csrfToken // Include CSRF token in request headers + }, + body: JSON.stringify({}), + }) + .then((response) => { + if (!response.ok) throw new Error("Failed to generate share link"); + return response.text(); + }); +} + +function copyShareLink() { + const shareLink = document.getElementById("shareLink"); + shareLink.select(); + shareLink.setSelectionRange(0, 99999); // For mobile devices + navigator.clipboard.writeText(shareLink.value).then(() => { + alert("Share link copied to clipboard!"); + }); } \ No newline at end of file diff --git a/src/main/resources/templates/file-share-view.html b/src/main/resources/templates/file-share-view.html new file mode 100644 index 0000000..5c6e624 --- /dev/null +++ b/src/main/resources/templates/file-share-view.html @@ -0,0 +1,44 @@ + + + + + Shared File View + + + + +
+

Shared File

+
+
+
+
+
File Name
+ +

Description

+ +
+
Uploaded At:
+

+
+ +
+
File Size:
+

+
+ +
+ + Download + +
+
+
+
+
+ + + diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index f0833ef..19b93e9 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -2,16 +2,13 @@ - - File - View - - - + + File View + + + + + +
-

- File - View

+

File View

-
- File - Name
- +
File Name
-

+

+
-

+

- + Files are kept only for 30 days after this date. +
Keep Indefinitely:
@@ -94,16 +81,17 @@
+ value="true">
+
Hide File From List:
@@ -112,57 +100,46 @@
+ th:checked="${file.hidden}" + value="true">
-
- File - Size:
-

+
File Size:
+

-
- Link -
+ +
Link
-
@@ -191,6 +177,29 @@
+ + + diff --git a/src/main/resources/templates/invalid-share-link.html b/src/main/resources/templates/invalid-share-link.html new file mode 100644 index 0000000..137b993 --- /dev/null +++ b/src/main/resources/templates/invalid-share-link.html @@ -0,0 +1,50 @@ + + + + + + Share Link Invalid + + + + + +
+
+
+

Link Expired

+

+ This share link is no longer valid. The file you are trying to access has expired or the link has been + used. +

+ Return to Homepage +
+
+
+ + + \ No newline at end of file