mirror of
https://github.com/RoastSlav/quickdrop.git
synced 2025-12-20 13:59:36 -06:00
Added a custom number of downloads for unrestricted share links
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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="row">
|
||||
<!-- Days Valid -->
|
||||
<div class="col-md-6">
|
||||
<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">
|
||||
<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}">
|
||||
|
||||
Reference in New Issue
Block a user