mirror of
https://github.com/RoastSlav/quickdrop.git
synced 2025-12-30 19:20:14 -06:00
added a share button that will work once without password protection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
}
|
||||
44
src/main/resources/templates/file-share-view.html
Normal file
44
src/main/resources/templates/file-share-view.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
50
src/main/resources/templates/invalid-share-link.html
Normal file
50
src/main/resources/templates/invalid-share-link.html
Normal 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>
|
||||
Reference in New Issue
Block a user