Added a custom number of downloads for unrestricted share links

This commit is contained in:
Rostislav Raykov
2025-03-18 20:48:21 +02:00
parent 1c861792b3
commit 9717aed865
9 changed files with 139 additions and 55 deletions

View File

@@ -2,6 +2,7 @@ package org.rostislav.quickdrop.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.rostislav.quickdrop.entity.FileEntity;
import org.rostislav.quickdrop.entity.ShareTokenEntity;
import org.rostislav.quickdrop.service.FileService;
import org.rostislav.quickdrop.service.SessionService;
import org.rostislav.quickdrop.util.FileUtils;
@@ -66,23 +67,23 @@ public class FileRestController {
}
@PostMapping("/share/{uuid}")
public ResponseEntity<String> generateShareableLink(@PathVariable String uuid, @RequestParam("expirationDate") LocalDate expirationDate, HttpServletRequest request) {
public ResponseEntity<String> generateShareableLink(@PathVariable String uuid, @RequestParam("expirationDate") LocalDate expirationDate, @RequestParam("nOfDownloads") int numberOfDownloads, HttpServletRequest request) {
FileEntity fileEntity = fileService.getFile(uuid);
if (fileEntity == null) {
return ResponseEntity.badRequest().body("File not found.");
}
String token;
ShareTokenEntity token;
if (fileEntity.passwordHash != null && !fileEntity.passwordHash.isEmpty()) {
String sessionToken = (String) request.getSession().getAttribute("file-session-token");
if (sessionToken == null || !sessionService.validateFileSessionToken(sessionToken, uuid)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
token = fileService.generateShareToken(uuid, expirationDate, sessionToken);
token = fileService.generateShareToken(uuid, expirationDate, sessionToken, numberOfDownloads);
} else {
token = fileService.generateShareToken(uuid, expirationDate);
token = fileService.generateShareToken(uuid, expirationDate, numberOfDownloads);
}
String shareLink = FileUtils.getShareLink(request, fileEntity, token);
String shareLink = FileUtils.getShareLink(request, token.shareToken);
return ResponseEntity.ok(shareLink);
}

View File

@@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpSession;
import org.rostislav.quickdrop.entity.DownloadLog;
import org.rostislav.quickdrop.entity.FileEntity;
import org.rostislav.quickdrop.entity.FileRenewalLog;
import org.rostislav.quickdrop.entity.ShareTokenEntity;
import org.rostislav.quickdrop.model.FileActionLogDTO;
import org.rostislav.quickdrop.model.FileEntityView;
import org.rostislav.quickdrop.service.AnalyticsService;
@@ -158,19 +159,21 @@ public class FileViewController {
return "redirect:/file/list";
}
@GetMapping("/share/{uuid}/{token}")
public String viewSharedFile(@PathVariable String uuid, @PathVariable String token, Model model) {
if (!fileService.validateShareToken(uuid, token)) {
@GetMapping("/share/{token}")
public String viewSharedFile(@PathVariable String token, Model model) {
ShareTokenEntity tokenEntity = fileService.getShareTokenEntityByToken(token);
if (!fileService.validateShareToken(tokenEntity)) {
return "invalid-share-link";
}
FileEntity file = fileService.getFile(uuid);
FileEntity file = fileService.getFile(tokenEntity.file.uuid);
if (file == null) {
return "redirect:/file/list";
}
model.addAttribute("file", new FileEntityView(file, analyticsService.getTotalDownloadsByFile(uuid)));
model.addAttribute("downloadLink", "/api/file/download/" + uuid + "/" + token);
model.addAttribute("file", new FileEntityView(file, analyticsService.getTotalDownloadsByFile(file.uuid)));
model.addAttribute("downloadLink", "/api/file/download/" + file.uuid + "/" + token);
return "file-share-view";
}

View File

@@ -19,11 +19,6 @@ 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() {
@@ -40,7 +35,7 @@ public class FileEntity {
", size=" + size +
", keepIndefinitely=" + keepIndefinitely +
", uploadDate=" + uploadDate +
", passwordHash='" + passwordHash + '\'' +
", hidden=" + hidden +
'}';
}
}

View File

@@ -0,0 +1,31 @@
package org.rostislav.quickdrop.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
public class ShareTokenEntity {
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "file_id", nullable = false)
public FileEntity file;
@Column(nullable = true)
public String shareToken;
@Column(nullable = true)
public LocalDate tokenExpirationDate;
@Column(nullable = true)
public Integer numberOfAllowedDownloads;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
public ShareTokenEntity() {
}
public ShareTokenEntity(String token, FileEntity file, LocalDate tokenExpirationDate, Integer numberOfDownloads) {
this.shareToken = token;
this.file = file;
this.tokenExpirationDate = tokenExpirationDate;
this.numberOfAllowedDownloads = numberOfDownloads;
}
}

View File

@@ -0,0 +1,15 @@
package org.rostislav.quickdrop.repository;
import org.rostislav.quickdrop.entity.ShareTokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface ShareTokenRepository extends JpaRepository<ShareTokenEntity, Long> {
@Query("SELECT s FROM ShareTokenEntity s WHERE s.file.uuid = :uuid")
List<ShareTokenEntity> getShareTokenEntitiesByUUID(String uuid);
@Query("SELECT s FROM ShareTokenEntity s WHERE s.shareToken = :shareToken")
ShareTokenEntity getShareTokenEntityByToken(String shareToken);
}

View File

@@ -5,11 +5,13 @@ import jakarta.transaction.Transactional;
import org.rostislav.quickdrop.entity.DownloadLog;
import org.rostislav.quickdrop.entity.FileEntity;
import org.rostislav.quickdrop.entity.FileRenewalLog;
import org.rostislav.quickdrop.entity.ShareTokenEntity;
import org.rostislav.quickdrop.model.FileEntityView;
import org.rostislav.quickdrop.model.FileUploadRequest;
import org.rostislav.quickdrop.repository.DownloadLogRepository;
import org.rostislav.quickdrop.repository.FileRepository;
import org.rostislav.quickdrop.repository.RenewalLogRepository;
import org.rostislav.quickdrop.repository.ShareTokenRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
@@ -52,9 +54,10 @@ public class FileService {
private final SessionService sessionService;
private final RenewalLogRepository renewalLogRepository;
private final FileEncryptionService fileEncryptionService;
private final ShareTokenRepository shareTokenRepository;
@Lazy
public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository, SessionService sessionService, RenewalLogRepository renewalLogRepository, FileEncryptionService fileEncryptionService) {
public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository, SessionService sessionService, RenewalLogRepository renewalLogRepository, FileEncryptionService fileEncryptionService, ShareTokenRepository shareTokenRepository) {
this.fileRepository = fileRepository;
this.passwordEncoder = passwordEncoder;
this.applicationSettingsService = applicationSettingsService;
@@ -62,6 +65,7 @@ public class FileService {
this.sessionService = sessionService;
this.renewalLogRepository = renewalLogRepository;
this.fileEncryptionService = fileEncryptionService;
this.shareTokenRepository = shareTokenRepository;
}
private static StreamingResponseBody getStreamingResponseBody(Path outputFile, FileEntity fileEntity) {
@@ -335,18 +339,8 @@ public class FileService {
}
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);
public boolean validateShareToken(ShareTokenEntity token) {
return (token.tokenExpirationDate == null || !LocalDate.now().isAfter(token.tokenExpirationDate)) && token.numberOfAllowedDownloads > 0;
}
public boolean checkFilePassword(String uuid, String password) {
@@ -361,8 +355,9 @@ public class FileService {
public StreamingResponseBody streamFileAndInvalidateToken(String uuid, String token, HttpServletRequest request) {
Optional<FileEntity> optionalFile = fileRepository.findByUUID(uuid);
ShareTokenEntity shareTokenEntity = shareTokenRepository.getShareTokenEntityByToken(token);
if (optionalFile.isEmpty() || !validateShareToken(uuid, token)) {
if (optionalFile.isEmpty() || !validateShareToken(shareTokenEntity)) {
return null;
}
@@ -402,10 +397,13 @@ public class FileService {
}
}
// Invalidate the share token
fileEntity.shareToken = null;
fileEntity.tokenExpirationDate = null;
fileRepository.save(fileEntity);
// Update and/or delete the share token
shareTokenEntity.numberOfAllowedDownloads--;
if (!validateShareToken(shareTokenEntity)) {
shareTokenRepository.delete(shareTokenEntity);
} else {
shareTokenRepository.save(shareTokenEntity);
}
logger.info("Share token invalidated and file streamed successfully: {}", fileEntity.name);
};
@@ -491,7 +489,7 @@ public class FileService {
renewalLogRepository.save(fileRenewalLog);
}
public String generateShareToken(String uuid, LocalDate tokenExpirationDate) {
public ShareTokenEntity generateShareToken(String uuid, LocalDate tokenExpirationDate, int numberOfDownloads) {
Optional<FileEntity> optionalFile = fileRepository.findByUUID(uuid);
if (optionalFile.isEmpty()) {
throw new IllegalArgumentException("File not found");
@@ -499,14 +497,13 @@ public class FileService {
FileEntity file = optionalFile.get();
String token = UUID.randomUUID().toString();
file.shareToken = token;
file.tokenExpirationDate = tokenExpirationDate;
fileRepository.save(file);
ShareTokenEntity shareToken = new ShareTokenEntity(token, file, tokenExpirationDate, numberOfDownloads);
shareTokenRepository.save(shareToken);
return token;
return shareToken;
}
public String generateShareToken(String uuid, LocalDate tokenExpirationDate, String sessionToken) {
public ShareTokenEntity generateShareToken(String uuid, LocalDate tokenExpirationDate, String sessionToken, int numberOfDownloads) {
Optional<FileEntity> optionalFile = fileRepository.findByUUID(uuid);
if (optionalFile.isEmpty()) {
throw new IllegalArgumentException("File not found");
@@ -529,13 +526,15 @@ public class FileService {
}
// Generate the share token
String token = UUID.randomUUID().toString();
file.shareToken = token;
file.tokenExpirationDate = tokenExpirationDate;
fileRepository.save(file);
ShareTokenEntity shareToken = generateShareToken(uuid, tokenExpirationDate, numberOfDownloads);
shareTokenRepository.save(shareToken);
logger.info("Share token generated for file: {}", file.name);
return token;
return shareToken;
}
public ShareTokenEntity getShareTokenEntityByToken(String token) {
return shareTokenRepository.getShareTokenEntityByToken(token);
}
private record RequesterInfo(String ipAddress, String userAgent) {

View File

@@ -30,8 +30,8 @@ public class FileUtils {
return scheme + "://" + 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 String getShareLink(HttpServletRequest request, String token) {
return request.getScheme() + "://" + request.getServerName() + "/file/share/" + token;
}
public static long bytesToMegabytes(long bytes) {

View File

@@ -41,13 +41,13 @@ function openShareModal() {
shareModal.show();
}
function generateShareLink(fileUuid, daysValid) {
function generateShareLink(fileUuid, daysValid, allowedNumberOfDownloads) {
const csrfToken = document.querySelector('meta[name="_csrf"]').content;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + daysValid);
const expirationDateStr = expirationDate.toISOString().split('T')[0];
return fetch(`/api/file/share/${fileUuid}?expirationDate=${expirationDateStr}`, {
return fetch(`/api/file/share/${fileUuid}?expirationDate=${expirationDateStr}&nOfDownloads=${allowedNumberOfDownloads}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -77,19 +77,26 @@ function createShareLink() {
const fileUuid = document.getElementById('fileUuid').textContent.trim();
const daysValidInput = document.getElementById('daysValid');
const daysValid = parseInt(daysValidInput.value, 10);
const allowedNumberOfDownloadsInput = document.getElementById('allowedNumberOfDownloadsCount');
const allowedNumberOfDownloads = parseInt(allowedNumberOfDownloadsInput.value, 10);
if (isNaN(daysValid) || daysValid < 1) {
alert("Please enter a valid number of days.");
return;
}
if (isNaN(allowedNumberOfDownloads) || allowedNumberOfDownloads < 1) {
alert("Please enter a valid number of downloads.");
return;
}
const spinner = document.getElementById('spinner');
const generateLinkButton = document.getElementById('generateLinkButton');
spinner.style.display = 'inline-block';
generateLinkButton.disabled = true;
generateShareLink(fileUuid, daysValid)
generateShareLink(fileUuid, daysValid, allowedNumberOfDownloads)
.then((shareLink) => {
updateShareLink(shareLink); // Update with the token-based link
})
@@ -116,12 +123,15 @@ function toggleLinkType() {
const unrestrictedLinkCheckbox = document.getElementById('unrestrictedLink');
const daysValidContainer = document.getElementById('daysValidContainer');
const generateLinkButton = document.getElementById('generateLinkButton');
const allowedNumberOfDownloads = document.getElementById('allowedNumberOfDownloads');
if (unrestrictedLinkCheckbox.checked) {
daysValidContainer.style.display = 'block';
allowedNumberOfDownloads.style.display = 'block';
generateLinkButton.disabled = false;
} else {
daysValidContainer.style.display = 'none';
allowedNumberOfDownloads.style.display = 'none';
generateLinkButton.disabled = true;
initializeModal();
}

View File

@@ -181,7 +181,8 @@
By default, this link requires a password to access the file if the file is password-protected
or if the app password is enabled.
<br>
You can generate an unrestricted link valid for a specific number of days, usable once.
You can generate an unrestricted link valid for a specific number of days and specific number of
downloads.
</p>
</div>
@@ -200,9 +201,38 @@
<label class="form-check-label" for="unrestrictedLink">Generate an unrestricted link</label>
</div>
<div class="mb-3" id="daysValidContainer" style="display: none;">
<label class="form-label" for="daysValid">Number of days the link will be valid:</label>
<input class="form-control" id="daysValid" min="1" type="number" value="30">
<div class="row">
<!-- Days Valid -->
<div class="col-md-6">
<div class="mb-3" id="daysValidContainer" style="display: none;">
<label class="form-label" for="daysValid">
Days the link will be valid:
</label>
<input
class="form-control"
id="daysValid"
min="1"
type="number"
value="30"
>
</div>
</div>
<!-- Allowed Downloads -->
<div class="col-md-6">
<div class="mb-3" id="allowedNumberOfDownloads" style="display: none;">
<label class="form-label" for="allowedNumberOfDownloadsCount">
Number of allowed downloads:
</label>
<input
class="form-control"
id="allowedNumberOfDownloadsCount"
min="1"
type="number"
value="1"
>
</div>
</div>
</div>
</div>
<div class="modal-footer" th:if="${file.passwordHash != null || isAppPasswordSet == true}">