added a share button that will work once without password protection

This commit is contained in:
Rostislav Raykov
2024-12-08 16:12:16 +02:00
parent 788209e442
commit 7659a24836
10 changed files with 373 additions and 79 deletions

View File

@@ -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

View File

@@ -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<String> 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<StreamingResponseBody> 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();
}
}
}

View File

@@ -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";
}
}

View File

@@ -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() {

View File

@@ -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<FileEntity> 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<FileEntity> 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<FileEntity> 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);
}
};
}
}

View File

@@ -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));

View File

@@ -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!");
});
}

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shared File View</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">Shared File</h1>
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-body">
<h5 class="card-title text-center" th:text="${file.name}">File Name</h5>
<p class="card-text text-center" th:text="${file.description}">Description</p>
<div class="d-flex justify-content-between align-items-center border-top pt-3">
<h5 class="card-title mb-0">Uploaded At:</h5>
<p class="card-text mb-0" th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
</div>
<div class="d-flex justify-content-between align-items-center pt-3">
<h5 class="card-title">File Size:</h5>
<p class="card-text" th:text="${file.size}"></p>
</div>
<h5 class="card-title border-top pt-3"></h5>
<a
class="btn btn-success w-100"
id="downloadButton"
th:href="${downloadLink}">
Download
</a>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -2,16 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>
File
View</title>
<meta content="width=device-width, initial-scale=1"
name="viewport">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet">
<link href="/images/favicon.png"
rel="icon"
type="image/png">
<meta name="_csrf" th:content="${_csrf.token}"/>
<title>File View</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet">
<link href="/images/favicon.png" rel="icon" type="image/png">
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.4.4/build/qrcode.min.js"></script>
<style>
.copyButton.copied {
@@ -22,6 +19,7 @@
</style>
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
@@ -32,60 +30,49 @@
<button
class="navbar-toggler"
type="button"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
data-bs-toggle="collapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/file/list">View Files</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/file/upload">Upload File</a>
</li>
<li class="nav-item">
<!-- Admin Dashboard Button -->
<a class="nav-link" href="/admin/dashboard" onclick="requestAdminPassword()">Admin Dashboard</a>
</li>
<li class="nav-item"><a class="nav-link" href="/file/list">View Files</a></li>
<li class="nav-item"><a class="nav-link" href="/file/upload">Upload File</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/dashboard" onclick="requestAdminPassword()">Admin
Dashboard</a></li>
</ul>
</div>
</div>
</nav>
<!-- Hidden element to retrieve file ID -->
<span hidden id="fileId" th:text="${file.id}"></span>
<!-- Main Content -->
<div class="container mt-5">
<h1 class="text-center mb-4">
File
View</h1>
<h1 class="text-center mb-4">File View</h1>
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-body">
<h5 class="card-title text-center"
th:text="${file.name}">
File
Name</h5>
<h5 class="card-title text-center" th:text="${file.name}">File Name</h5>
<div th:if="${!#strings.isEmpty(file.description)}">
<p class="card-text text-center mb-3"
th:text="${file.description}"></p>
<p class="card-text text-center mb-3" th:text="${file.description}"></p>
</div>
<!-- File info -->
<div class="d-flex justify-content-between align-items-center border-top pt-3">
<h5 class="card-title mb-0"
th:text="${file.keepIndefinitely} ? 'Uploaded At:' : 'Uploaded/Renewed At:'"></h5>
<p class="card-text mb-0"
th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
<p class="card-text mb-0" th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
</div>
<small class="text-muted"
th:if="${file.keepIndefinitely == false}">
<small class="text-muted" th:if="${file.keepIndefinitely == false}">
Files are kept only for <span th:text="${maxFileLifeTime}">30</span> days after this date.
</small>
<div class="d-flex justify-content-between align-items-center pt-3">
<h5 class="card-title">Keep Indefinitely:</h5>
<form class="d-inline" method="post" th:action="@{/file/keep-indefinitely/{id}(id=${file.id})}">
@@ -94,16 +81,17 @@
<div class="form-check form-switch">
<input
class="form-check-input"
onchange="updateCheckboxState(event, this)"
id="keepIndefinitely"
name="keepIndefinitely"
onchange="updateCheckboxState(event, this)"
type="checkbox"
th:checked="${file.keepIndefinitely}"
th:disabled="${file.passwordHash == null}"
type="checkbox"
value="true"/>
value="true">
</div>
</form>
</div>
<div class="d-flex justify-content-between align-items-center" th:if="${file.passwordHash != null}">
<h5 class="card-title">Hide File From List:</h5>
<form class="d-inline" method="post" th:action="@{/file/toggle-hidden/{id}(id=${file.id})}">
@@ -112,57 +100,46 @@
<div class="form-check form-switch">
<input
class="form-check-input"
onchange="updateCheckboxState(event, this)"
id="hidden"
name="hidden"
onchange="updateCheckboxState(event, this)"
th:checked="${file.hidden}"
type="checkbox"
value="true"/>
th:checked="${file.hidden}"
value="true">
</div>
</form>
</div>
<div class="d-flex justify-content-between align-items-center pt-3">
<h5 class="card-title">
File
Size:</h5>
<p class="card-text"
th:text="${fileSize}"></p>
<h5 class="card-title">File Size:</h5>
<p class="card-text" th:text="${fileSize}"></p>
</div>
<h5 class="card-title border-top pt-3">
Link
</h5>
<!-- Link and QR -->
<h5 class="card-title border-top pt-3">Link</h5>
<div class="input-group mb-3 align-items-center">
<input
th:value="${downloadLink}"
class="form-control"
id="downloadLink"
readonly
th:value="${downloadLink}"
type="text"
readonly
style="height: 38px;"/>
<button
type="button"
class="btn btn-outline-secondary copyButton"
onclick="copyToClipboard(this)"
type="button"
style="height: 38px;">
Copy Link
</button>
<canvas id="qrCodeContainer" style="width: 100px; height: 100px;"></canvas>
</div>
<div class="alert alert-info"
id="preparingMessage"
style="display: none;">
Your
file
is
being
prepared
for
download.
Please
wait...
<div
class="alert alert-info"
id="preparingMessage"
style="display: none;">
Your file is being prepared for download. Please wait...
</div>
<div class="d-flex justify-content-between mt-3 border-top pt-3">
@@ -170,20 +147,29 @@
class="btn btn-success"
id="downloadButton"
th:href="@{/file/download/{id}(id=${file.id})}"
th:onclick="${file.passwordHash != null} ? 'showPreparingMessage()' : ''"
>
th:onclick="${file.passwordHash != null} ? 'showPreparingMessage()' : ''">
Download
</a>
<form method="post" onsubmit="return confirmDelete();"
th:action="@{/file/delete/{id}(id=${file.id})}">
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden"/>
<form method="post"
onsubmit="return confirmDelete();"
th:action="@{/file/delete/{id}(id=${file.id})}"
th:if="${file.passwordHash != null}">
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
<button class="btn btn-danger" type="submit">Delete File</button>
</form>
<form method="post" th:action="@{/file/extend/{id}(id=${file.id})}"
<form method="post"
th:action="@{/file/extend/{id}(id=${file.id})}"
th:if="${file.keepIndefinitely == false}">
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden"/>
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
<button class="btn btn-primary" type="submit">Renew File Lifetime</button>
</form>
<!-- New Share Button -->
<button class="btn btn-secondary" onclick="openShareModal()" th:if="${file.passwordHash != null}"
type="button">Share
</button>
</div>
</div>
</div>
@@ -191,6 +177,29 @@
</div>
</div>
<!-- Share Modal -->
<div aria-hidden="true" aria-labelledby="shareModalLabel" class="modal fade" id="shareModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="shareModalLabel">Share File</h1>
<button aria-label="Close" class="btn-close" data-bs-dismiss="modal" type="button"></button>
</div>
<div class="modal-body">
<p>This link can be used to share the file once without any password protection. It will be valid for 30
days.</p>
<div class="input-group mb-3">
<input class="form-control" id="shareLink" readonly type="text">
<button class="btn btn-outline-secondary" onclick="copyShareLink()" type="button">Copy Link</button>
</div>
<div class="text-center">
<canvas id="shareQRCode" style="width: 150px; height: 150px;"></canvas>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/fileView.js"></script>
</body>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Share Link Invalid</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/images/favicon.png" rel="icon" type="image/png">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f8f9fa;
}
.card {
max-width: 500px;
width: 100%;
border: none;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-body {
text-align: center;
}
.btn-home {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container d-flex justify-content-center align-items-center">
<div class="card">
<div class="card-body">
<h1 class="card-title">Link Expired</h1>
<p class="card-text text-muted">
This share link is no longer valid. The file you are trying to access has expired or the link has been
used.
</p>
<a class="btn btn-primary btn-home" href="/">Return to Homepage</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>