From b7332eac35570fe50d02a6e5c8270c1e028bfb87 Mon Sep 17 00:00:00 2001 From: Rostislav <47450671+RoastSlav@users.noreply.github.com> Date: Sat, 29 Mar 2025 16:36:31 +0200 Subject: [PATCH 1/4] Improved upload and download (#34) * outdated tests * Much optimized upload process * Much optimized upload process * Much optimized download process --------- Co-authored-by: Rostislav Raykov --- .../quickdrop/config/SecurityConfig.java | 2 +- .../controller/FileRestController.java | 34 +- .../quickdrop/entity/ShareTokenEntity.java | 6 +- .../rostislav/quickdrop/model/ChunkInfo.java | 19 ++ .../quickdrop/model/FileUploadRequest.java | 8 +- .../service/AsyncFileMergeService.java | 139 +++++++++ .../service/FileEncryptionService.java | 63 ++-- .../quickdrop/service/FileService.java | 295 +++++------------- .../quickdrop/util/DataValidator.java | 4 +- src/main/resources/application.properties | 2 +- src/main/resources/static/js/upload.js | 58 ++-- .../rostislav/quickdrop/FileServiceTests.java | 157 ---------- .../quickdrop/QuickdropApplicationTests.java | 16 - .../quickdrop/TestDataContainer.java | 32 -- 14 files changed, 326 insertions(+), 509 deletions(-) create mode 100644 src/main/java/org/rostislav/quickdrop/model/ChunkInfo.java create mode 100644 src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java delete mode 100644 src/test/java/org/rostislav/quickdrop/FileServiceTests.java delete mode 100644 src/test/java/org/rostislav/quickdrop/QuickdropApplicationTests.java delete mode 100644 src/test/java/org/rostislav/quickdrop/TestDataContainer.java diff --git a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 95aef9d..ac57cc5 100644 --- a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java @@ -30,8 +30,8 @@ import java.util.List; @Configuration @EnableWebSecurity public class SecurityConfig { - private final ApplicationSettingsService applicationSettingsService; private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class); + private final ApplicationSettingsService applicationSettingsService; public SecurityConfig(ApplicationSettingsService applicationSettingsService) { this.applicationSettingsService = applicationSettingsService; diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index eb82c66..252900a 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -3,6 +3,8 @@ 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.model.FileUploadRequest; +import org.rostislav.quickdrop.service.AsyncFileMergeService; import org.rostislav.quickdrop.service.FileService; import org.rostislav.quickdrop.service.SessionService; import org.rostislav.quickdrop.util.FileUtils; @@ -16,8 +18,8 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.io.IOException; import java.time.LocalDate; -import java.util.HashMap; -import java.util.Map; + +import static org.springframework.http.ResponseEntity.ok; @RestController @RequestMapping("/api/file") @@ -25,10 +27,12 @@ public class FileRestController { private static final Logger logger = LoggerFactory.getLogger(FileRestController.class); private final FileService fileService; private final SessionService sessionService; + private final AsyncFileMergeService asyncFileMergeService; - public FileRestController(FileService fileService, SessionService sessionService) { + public FileRestController(FileService fileService, SessionService sessionService, AsyncFileMergeService asyncFileMergeService) { this.fileService = fileService; this.sessionService = sessionService; + this.asyncFileMergeService = asyncFileMergeService; } @PostMapping("/upload-chunk") @@ -37,33 +41,27 @@ public class FileRestController { @RequestParam("fileName") String fileName, @RequestParam("chunkNumber") int chunkNumber, @RequestParam("totalChunks") int totalChunks, + @RequestParam(value = "fileSize", required = false) Long fileSize, @RequestParam(value = "description", required = false) String description, @RequestParam(value = "keepIndefinitely", defaultValue = "false") Boolean keepIndefinitely, @RequestParam(value = "password", required = false) String password, @RequestParam(value = "hidden", defaultValue = "false") Boolean hidden) { + if (chunkNumber == 0) { logger.info("Upload started for file: {}", fileName); } try { - logger.info("Saving chunk {} of {}", chunkNumber, totalChunks); - fileService.saveFileChunk(file, fileName, chunkNumber); - - if (chunkNumber + 1 == totalChunks) { - logger.info("All chunks uploaded for file: {} - Finalizing", fileName); - return fileService.finalizeFile(fileName, totalChunks, description, keepIndefinitely, password, hidden); - } + logger.info("Submitting chunk {} of {} for file: {}", chunkNumber, totalChunks, fileName); + FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden, fileName, totalChunks, fileSize); + FileEntity fileEntity = asyncFileMergeService.submitChunk(fileUploadRequest, file, chunkNumber); + return ResponseEntity.ok(fileEntity); } catch (IOException e) { - fileService.deleteChunkFilesFromTemp(fileName); - fileService.deleteFullFileFromTemp(fileName); + logger.error("Error processing chunk {} for file {}: {}", chunkNumber, fileName, e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("{\"error\": \"Error processing chunk\"}"); } - - Map response = new HashMap<>(); - response.put("message", "Chunk " + chunkNumber + " uploaded successfully"); - return ResponseEntity.ok(response); } @PostMapping("/share/{uuid}") @@ -84,7 +82,7 @@ public class FileRestController { token = fileService.generateShareToken(uuid, expirationDate, numberOfDownloads); } String shareLink = FileUtils.getShareLink(request, token.shareToken); - return ResponseEntity.ok(shareLink); + return ok(shareLink); } @GetMapping("/download/{uuid}/{token}") @@ -97,7 +95,7 @@ public class FileRestController { FileEntity fileEntity = fileService.getFile(uuid); - return ResponseEntity.ok() + return ok() .header("Content-Disposition", "attachment; filename=\"" + fileEntity.name + "\"") .header("Content-Type", "application/octet-stream") .body(responseBody); diff --git a/src/main/java/org/rostislav/quickdrop/entity/ShareTokenEntity.java b/src/main/java/org/rostislav/quickdrop/entity/ShareTokenEntity.java index d91aaf2..38535e9 100644 --- a/src/main/java/org/rostislav/quickdrop/entity/ShareTokenEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/ShareTokenEntity.java @@ -6,15 +6,15 @@ import java.time.LocalDate; @Entity public class ShareTokenEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "file_id", nullable = false) public FileEntity file; public String shareToken; public LocalDate tokenExpirationDate; public Integer numberOfAllowedDownloads; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; public ShareTokenEntity() { } diff --git a/src/main/java/org/rostislav/quickdrop/model/ChunkInfo.java b/src/main/java/org/rostislav/quickdrop/model/ChunkInfo.java new file mode 100644 index 0000000..39add0b --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/ChunkInfo.java @@ -0,0 +1,19 @@ +package org.rostislav.quickdrop.model; + +import java.io.File; + +public class ChunkInfo { + public int chunkNumber; + public File chunkFile; + public boolean isLastChunk; + + public ChunkInfo() { + + } + + public ChunkInfo(int chunkNumber, File chunkFile, boolean isLastChunk) { + this.chunkNumber = chunkNumber; + this.chunkFile = chunkFile; + this.isLastChunk = isLastChunk; + } +} \ No newline at end of file diff --git a/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java b/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java index 653aa51..45f6177 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java +++ b/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java @@ -1,6 +1,9 @@ package org.rostislav.quickdrop.model; public class FileUploadRequest { + public String fileName; + public int totalChunks; + public Long fileSize; public String description; public boolean keepIndefinitely; public String password; @@ -9,10 +12,13 @@ public class FileUploadRequest { public FileUploadRequest() { } - public FileUploadRequest(String description, boolean keepIndefinitely, String password, boolean hidden) { + public FileUploadRequest(String description, boolean keepIndefinitely, String password, boolean hidden, String fileName, int totalChunks, Long fileSize) { this.description = description; this.keepIndefinitely = keepIndefinitely; this.password = password; this.hidden = hidden; + this.fileName = fileName; + this.totalChunks = totalChunks; + this.fileSize = fileSize; } } diff --git a/src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java b/src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java new file mode 100644 index 0000000..3cf642d --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java @@ -0,0 +1,139 @@ +package org.rostislav.quickdrop.service; + +import org.rostislav.quickdrop.entity.FileEntity; +import org.rostislav.quickdrop.model.ChunkInfo; +import org.rostislav.quickdrop.model.FileUploadRequest; +import org.rostislav.quickdrop.repository.FileRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.concurrent.*; + +@Service +public class AsyncFileMergeService { + private static final Logger logger = LoggerFactory.getLogger(AsyncFileMergeService.class); + private final ConcurrentMap mergeTasks = new ConcurrentHashMap<>(); + private final ExecutorService executorService = Executors.newCachedThreadPool(); + private final ApplicationSettingsService applicationSettingsService; + private final FileEncryptionService fileEncryptionService; + private final FileService fileService; + + private final File tempDir = new File(System.getProperty("java.io.tmpdir")); + private final FileRepository fileRepository; + + public AsyncFileMergeService(ApplicationSettingsService applicationSettingsService, + FileEncryptionService fileEncryptionService, + FileService fileService, FileRepository fileRepository) { + this.applicationSettingsService = applicationSettingsService; + this.fileEncryptionService = fileEncryptionService; + this.fileService = fileService; + this.fileRepository = fileRepository; + } + + public FileEntity submitChunk(FileUploadRequest request, MultipartFile multipartChunk, int chunkNumber) throws IOException { + File savedChunk = new File(tempDir, request.fileName + "_chunk_" + chunkNumber); + multipartChunk.transferTo(savedChunk); + logger.info("Chunk {} for file {} saved to {}", chunkNumber, request.fileName, savedChunk.getAbsolutePath()); + + MergeTask mergeTask = mergeTasks.computeIfAbsent(request.fileName, key -> { + MergeTask task = new MergeTask(request); + executorService.submit(task); + return task; + }); + boolean isLastChunk = (chunkNumber == request.totalChunks - 1); + mergeTask.enqueueChunk(new ChunkInfo(chunkNumber, savedChunk, isLastChunk)); + + if (isLastChunk) { + try { + return mergeTask.getMergeCompletionFuture().get(); + } catch (InterruptedException | ExecutionException e) { + logger.error("Error waiting for merge completion: {}", e.getMessage()); + Thread.currentThread().interrupt(); + throw new IOException("Merge task interrupted", e); + } + } + return null; + } + + private void cleanUpChunks(FileUploadRequest request) { + for (int i = 0; i < request.totalChunks; i++) { + File chunkFile = new File(tempDir, request.fileName + "_chunk_" + i); + if (chunkFile.exists() && !chunkFile.delete()) { + logger.warn("Failed to delete chunk file: {}", chunkFile.getAbsolutePath()); + } + logger.info("Cleaning up chunk {}", i); + } + } + + private class MergeTask implements Runnable { + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final CompletableFuture mergeCompletionFuture = new CompletableFuture<>(); + private final FileUploadRequest request; + private int processedChunks = 0; + private String uuid; + + MergeTask(FileUploadRequest request) { + this.request = request; + do { + uuid = UUID.randomUUID().toString(); + } while (fileRepository.findByUUID(uuid).isPresent()); + } + + public void enqueueChunk(ChunkInfo chunkInfo) { + queue.add(chunkInfo); + } + + public CompletableFuture getMergeCompletionFuture() { + return mergeCompletionFuture; + } + + @Override + public void run() { + File finalFile = Paths.get(applicationSettingsService.getFileStoragePath(), uuid).toFile(); + + try (OutputStream finalOut = fileService.shouldEncrypt(request) ? + fileEncryptionService.getEncryptedOutputStream(finalFile, request.password) : + new BufferedOutputStream(new FileOutputStream(finalFile, true))) { + + while (processedChunks < request.totalChunks) { + ChunkInfo info = queue.take(); + try (InputStream in = new BufferedInputStream(new FileInputStream(info.chunkFile))) { + in.transferTo(finalOut); + } + + if (!info.chunkFile.delete()) { + logger.warn("Failed to delete chunk file: {}", info.chunkFile.getAbsolutePath()); + } + + processedChunks++; + logger.info("Merged chunk {} for file {}", info.chunkNumber, request.fileName); + if (info.isLastChunk) { + break; + } + } + logger.info("All {} chunks merged for file {}", request.totalChunks, request.fileName); + + FileEntity fileEntity = fileService.saveFile(finalFile, request, uuid); + if (fileEntity != null) { + logger.info("File {} saved successfully with UUID {}", request.fileName, fileEntity.uuid); + } else { + logger.error("Saving file {} failed", request.fileName); + } + mergeCompletionFuture.complete(fileEntity); + } catch (Exception e) { + logger.error("Error merging chunks for file {}: {}", request.fileName, e.getMessage()); + mergeCompletionFuture.completeExceptionally(e); + cleanUpChunks(request); + e.printStackTrace(); + } finally { + mergeTasks.remove(request.fileName); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java b/src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java index f97e57c..f56f1f9 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java @@ -6,10 +6,7 @@ import javax.crypto.*; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; +import java.io.*; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -27,7 +24,7 @@ public class FileEncryptionService { public SecretKey generateKeyFromPassword(String password, byte[] salt) throws NoSuchAlgorithmException, InvalidKeySpecException { PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH); - SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM); byte[] keyBytes = keyFactory.generateSecret(spec).getEncoded(); return new SecretKeySpec(keyBytes, "AES"); } @@ -38,30 +35,6 @@ public class FileEncryptionService { return bytes; } - public void encryptFile(File inputFile, File outputFile, String password) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, IOException, InvalidAlgorithmParameterException, InvalidKeyException { - byte[] salt = generateRandomBytes(); - byte[] iv = generateRandomBytes(); - SecretKey secretKey = generateKeyFromPassword(password, salt); - IvParameterSpec ivSpec = new IvParameterSpec(iv); - - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); - - try (FileOutputStream fos = new FileOutputStream(outputFile); - CipherOutputStream cos = new CipherOutputStream(fos, cipher); - FileInputStream fis = new FileInputStream(inputFile)) { - - fos.write(salt); - fos.write(iv); - - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = fis.read(buffer)) != -1) { - cos.write(buffer, 0, bytesRead); - } - } - } - @SuppressWarnings("ResultOfMethodCallIgnored") public void decryptFile(File inputFile, File outputFile, String password) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidKeyException { try (FileInputStream fis = new FileInputStream(inputFile)) { @@ -87,4 +60,36 @@ public class FileEncryptionService { } } } + + public InputStream getDecryptedInputStream(File inputFile, String password) throws Exception { + FileInputStream fis = new FileInputStream(inputFile); + byte[] salt = new byte[16]; + byte[] iv = new byte[16]; + + fis.read(salt); + fis.read(iv); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + SecretKey secretKey = generateKeyFromPassword(password, salt); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); + CipherInputStream cipherInputStream = new CipherInputStream(fis, cipher); + return cipherInputStream; + } + + public OutputStream getEncryptedOutputStream(File finalFile, String password) throws Exception { + FileOutputStream fos = new FileOutputStream(finalFile, true); + byte[] salt = generateRandomBytes(); + byte[] iv = generateRandomBytes(); + + fos.write(salt); + fos.write(iv); + + SecretKey secretKey = generateKeyFromPassword(password, salt); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); + return new CipherOutputStream(fos, cipher); + } } \ No newline at end of file diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index ce99c51..36ecb8a 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -15,14 +15,11 @@ import org.rostislav.quickdrop.repository.ShareTokenRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; -import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.io.*; @@ -30,8 +27,6 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -47,7 +42,6 @@ public class FileService { private final PasswordEncoder passwordEncoder; private final ApplicationSettingsService applicationSettingsService; private final DownloadLogRepository downloadLogRepository; - private final File tempDir = Paths.get(System.getProperty("java.io.tmpdir")).toFile(); private final SessionService sessionService; private final RenewalLogRepository renewalLogRepository; private final FileEncryptionService fileEncryptionService; @@ -65,146 +59,17 @@ public class FileService { this.shareTokenRepository = shareTokenRepository; } - private static StreamingResponseBody getStreamingResponseBody(Path outputFile, FileEntity fileEntity) { + private static StreamingResponseBody getStreamingResponseBody(InputStream inputStream) { return outputStream -> { - try (FileInputStream inputStream = new FileInputStream(outputFile.toFile())) { - byte[] buffer = new byte[1024]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - outputStream.flush(); - } finally { - if (fileEntity.passwordHash != null) { - try { - Files.delete(outputFile); - logger.info("Decrypted file deleted: {}", outputFile); - } catch ( - Exception e) { - logger.error("Error deleting decrypted file: {}", e.getMessage()); - } - } + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); } + outputStream.flush(); }; } - public void saveFileChunk(MultipartFile file, String fileName, int chunkNumber) throws IOException { - File chunkFile = new File(tempDir, getFileChunkName(fileName, chunkNumber)); - try (FileOutputStream fos = new FileOutputStream(chunkFile)) { - fos.write(file.getBytes()); - } - } - - public FileEntity assembleChunks(String fileName, int totalChunks, FileUploadRequest fileUploadRequest) throws IOException { - File finalFile = new File(tempDir, fileName); - - if (!finalFile.createNewFile()) { - throw new IOException("Failed to create new file"); - } - - mergeChunksIntoFile(finalFile, fileName, totalChunks); - deleteChunkFilesFromTemp(fileName); - - return saveFile(finalFile, fileUploadRequest); - } - - private void mergeChunksIntoFile(File finalFile, String fileName, int totalChunks) throws IOException { - try (FileOutputStream fos = new FileOutputStream(finalFile)) { - for (int i = 0; i < totalChunks; i++) { - File chunk = new File(tempDir, fileName + "_chunk_" + i); - try { - Files.copy(chunk.toPath(), fos); - Files.delete(chunk.toPath()); - } catch (IOException ex) { - logger.error("Error processing chunk {}: {}", i, ex.getMessage()); - throw ex; - } - } - logger.info("All chunks merged into file: {}", finalFile); - } - } - - public void deleteChunkFilesFromTemp(String fileName) { - File[] tempFiles = tempDir.listFiles((dir, name) -> name.startsWith(fileName + "_chunk_")); - - if (tempFiles != null) { - for (File tempFile : tempFiles) { - if (tempFile.delete()) { - logger.info("Deleted temp file: {}", tempFile); - } else { - logger.error("Failed to delete temp file: {}", tempFile); - } - } - } - } - - public void deleteFullFileFromTemp(String fileName) { - Path assembledFilePath = Paths.get(tempDir.getAbsolutePath(), fileName); - try { - if (Files.exists(assembledFilePath)) { - Files.delete(assembledFilePath); - logger.info("Deleted assembled file: {}", assembledFilePath); - } - } catch (IOException e) { - logger.error("Failed to delete assembled file: {}", assembledFilePath, e); - } - } - - public FileEntity saveFile(File file, FileUploadRequest fileUploadRequest) { - if (!validateObjects(file, fileUploadRequest)) { - return null; - } - - logger.info("Saving file: {}", file.getName()); - - String uuid = UUID.randomUUID().toString(); - while (fileRepository.findByUUID(uuid).isPresent()) { - uuid = UUID.randomUUID().toString(); - } - - Path targetPath = Path.of(applicationSettingsService.getFileStoragePath(), uuid); - - FileEntity fileEntity = populateFileEntity(file, fileUploadRequest, uuid); - - if (fileEntity.encrypted) { - if (!saveEncryptedFile(targetPath, file, fileUploadRequest)) return null; - } else { - if (!moveAndRenameUnencryptedFile(file, targetPath)) return null; - } - - logger.info("FileEntity inserted into database: {}", fileEntity); - return fileRepository.save(fileEntity); - } - - public List getFiles() { - return fileRepository.findAll(); - } - - private FileEntity populateFileEntity(File file, FileUploadRequest request, String uuid) { - FileEntity fileEntity = new FileEntity(); - fileEntity.name = file.getName(); - fileEntity.uuid = uuid; - fileEntity.description = request.description; - fileEntity.size = file.length(); - fileEntity.keepIndefinitely = request.keepIndefinitely; - fileEntity.hidden = request.hidden; - fileEntity.encrypted = request.password != null && !request.password.isBlank() && applicationSettingsService.isEncryptionEnabled(); - - if (request.password != null && !request.password.isBlank()) { - fileEntity.passwordHash = passwordEncoder.encode(request.password); - } - - return fileEntity; - } - - public FileEntity getFile(Long id) { - return fileRepository.findById(id).orElse(null); - } - - public FileEntity getFile(String uuid) { - return fileRepository.findByUUID(uuid).orElse(null); - } - private static RequesterInfo getRequesterInfo(HttpServletRequest request) { String forwardedFor = request.getHeader("X-Forwarded-For"); String realIp = request.getHeader("X-Real-IP"); @@ -223,6 +88,48 @@ public class FileService { return new RequesterInfo(ipAddress, userAgent); } + public FileEntity saveFile(File file, FileUploadRequest fileUploadRequest, String uuid) { + if (!validateObjects(file, fileUploadRequest)) { + return null; + } + + logger.info("Saving file: {}", file.getName()); + + FileEntity fileEntity = populateFileEntity(fileUploadRequest, uuid); + + logger.info("FileEntity inserted into database: {}", fileEntity); + return fileRepository.save(fileEntity); + } + + public List getFiles() { + return fileRepository.findAll(); + } + + public boolean shouldEncrypt(FileUploadRequest request) { + return request.password != null && !request.password.isBlank() && applicationSettingsService.isEncryptionEnabled(); + } + + private FileEntity populateFileEntity(FileUploadRequest request, String uuid) { + FileEntity fileEntity = new FileEntity(); + fileEntity.name = request.fileName; + fileEntity.uuid = uuid; + fileEntity.description = request.description; + fileEntity.size = request.fileSize; + fileEntity.keepIndefinitely = request.keepIndefinitely; + fileEntity.hidden = request.hidden; + fileEntity.encrypted = shouldEncrypt(request); + + if (request.password != null && !request.password.isBlank()) { + fileEntity.passwordHash = passwordEncoder.encode(request.password); + } + + return fileEntity; + } + + public FileEntity getFile(String uuid) { + return fileRepository.findByUUID(uuid).orElse(null); + } + public boolean deleteFileFromFileSystem(String uuid) { Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid); try { @@ -270,13 +177,26 @@ public class FileService { Path filePath = Path.of(applicationSettingsService.getFileStoragePath(), fileEntity.uuid); String password = getFilePasswordFromSessionToken(request); - Path decryptedFilePath = decryptFileIfNeeded(fileEntity, filePath, password); - if (decryptedFilePath == null) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + + InputStream inputStream; + if (fileEntity.encrypted) { + try { + inputStream = fileEncryptionService.getDecryptedInputStream(filePath.toFile(), password); + } catch (Exception e) { + logger.error("Error decrypting file: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } else { + try { + inputStream = new FileInputStream(filePath.toFile()); + } catch (FileNotFoundException e) { + logger.error("File not found: {}", filePath); + return ResponseEntity.notFound().build(); + } } try { - return createFileDownloadResponse(decryptedFilePath, fileEntity, request); + return createFileDownloadResponse(inputStream, fileEntity, request); } catch (Exception e) { logger.error("Error preparing file download response: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); @@ -407,56 +327,6 @@ public class FileService { logger.info("Share token updated/invalidated. File streamed successfully: {}", fileEntity.name); } - public ResponseEntity finalizeFile(String fileName, int totalChunks, String description, - Boolean keepIndefinitely, String password, Boolean hidden) throws IOException { - FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden); - FileEntity fileEntity = assembleChunks(fileName, totalChunks, fileUploadRequest); - - if (fileEntity != null) { - return ResponseEntity.ok(fileEntity); - } - - return ResponseEntity.badRequest().build(); - } - - private boolean saveEncryptedFile(Path savePath, File file, FileUploadRequest fileUploadRequest) { - try { - Path encryptedFile = Files.createFile(savePath); - logger.info("Encrypting file: {}", encryptedFile); - fileEncryptionService.encryptFile(file, encryptedFile.toFile(), fileUploadRequest.password); - logger.info("Encrypted file saved: {}", encryptedFile); - } catch ( - Exception e) { - logger.error("Error encrypting file: {}", e.getMessage()); - return false; - } - - try { - Files.delete(file.toPath()); - logger.info("Temp file deleted: {}", file); - } catch ( - Exception e) { - logger.error("Error deleting temp file: {}", e.getMessage()); - return false; - } - - return true; - } - - private boolean moveAndRenameUnencryptedFile(File file, Path path) { - for (int retry = 0; retry < 3; retry++) { - try { - Files.move(file.toPath(), path, StandardCopyOption.REPLACE_EXISTING); - logger.info("File moved successfully: {}", path); - return true; - } catch (IOException e) { - logger.error("Attempt {}/3 failed to move file {}: {}", retry + 1, file.getName(), e.getMessage()); - } - } - logger.error("Failed to move file after 3 attempts: {}", file.getName()); - return false; - } - public FileEntity updateKeepIndefinitely(String uuid, boolean keepIndefinitely, HttpServletRequest request) { Optional referenceById = fileRepository.findByUUID(uuid); if (referenceById.isEmpty()) { @@ -512,7 +382,7 @@ public class FileService { Path decryptedFilePath = encryptedFilePath.resolveSibling(file.uuid + "-decrypted"); // Decrypt the file if necessary - if (file.passwordHash != null && !Files.exists(decryptedFilePath)) { + if (file.encrypted && !Files.exists(decryptedFilePath)) { try { String password = sessionService.getPasswordForFileSessionToken(sessionToken).getPassword(); fileEncryptionService.decryptFile(encryptedFilePath.toFile(), decryptedFilePath.toFile(), password); @@ -535,39 +405,15 @@ public class FileService { return shareTokenRepository.getShareTokenEntityByToken(token); } - private record RequesterInfo(String ipAddress, String userAgent) { - } - - private String getFileChunkName(String fileName, int chunkNumber) { - return fileName + "_chunk_" + chunkNumber; - } - - private Path decryptFileIfNeeded(FileEntity fileEntity, Path filePath, String password) { - if (!fileEntity.encrypted) { - return filePath; - } - try { - Path tempFile = File.createTempFile("Decrypted", "tmp").toPath(); - logger.info("Decrypting file: {}", filePath); - fileEncryptionService.decryptFile(filePath.toFile(), tempFile.toFile(), password); - logger.info("File decrypted: {}", tempFile); - return tempFile; - } catch (Exception e) { - logger.error("Error decrypting file: {}", e.getMessage()); - return null; - } - } - - private ResponseEntity createFileDownloadResponse(Path filePath, FileEntity fileEntity, HttpServletRequest request) throws IOException { - StreamingResponseBody responseBody = getStreamingResponseBody(filePath, fileEntity); - Resource resource = new UrlResource(filePath.toUri()); + private ResponseEntity createFileDownloadResponse(InputStream inputStream, FileEntity fileEntity, HttpServletRequest request) throws IOException { + StreamingResponseBody responseBody = getStreamingResponseBody(inputStream); logger.info("Sending file: {}", fileEntity); logDownload(fileEntity, request); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"") .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") - .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength())) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileEntity.size)) .header("X-Accel-Buffering", "no") .body(responseBody); } @@ -575,4 +421,7 @@ public class FileService { public boolean fileExistsInFileSystem(String uuid) { return Files.exists(Path.of(applicationSettingsService.getFileStoragePath(), uuid)); } + + private record RequesterInfo(String ipAddress, String userAgent) { + } } diff --git a/src/main/java/org/rostislav/quickdrop/util/DataValidator.java b/src/main/java/org/rostislav/quickdrop/util/DataValidator.java index 9889080..ada72cf 100644 --- a/src/main/java/org/rostislav/quickdrop/util/DataValidator.java +++ b/src/main/java/org/rostislav/quickdrop/util/DataValidator.java @@ -9,8 +9,8 @@ public class DataValidator { for (Object temp : objs) { if (temp != null) { if (temp instanceof String value && value.trim().isEmpty()) { - return false; - } + return false; + } } else { return false; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 04c7d39..d23f8a5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,4 +17,4 @@ server.error.path=/error spring.flyway.baseline-on-migrate=true spring.flyway.baseline-version=1 spring.flyway.locations=classpath:db/migration -app.version=1.4.2 \ No newline at end of file +app.version=1.4.3 \ No newline at end of file diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js index 429fbb2..9418aa4 100644 --- a/src/main/resources/static/js/upload.js +++ b/src/main/resources/static/js/upload.js @@ -50,6 +50,7 @@ function onUploadFormSubmit(event) { startChunkUpload(); } + function startChunkUpload() { const file = document.getElementById("file").files[0]; if (!file) { @@ -75,7 +76,7 @@ function startChunkUpload() { const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); - const formData = buildChunkFormData(chunk, currentChunk, file.name, totalChunks); + const formData = buildChunkFormData(chunk, currentChunk, file.name, totalChunks, file.size); const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/file/upload-chunk", true); @@ -88,33 +89,37 @@ function startChunkUpload() { xhr.onload = () => { if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - currentChunk++; - const percentComplete = (currentChunk / totalChunks) * 100; - progressBar.style.width = percentComplete + "%"; - progressBar.setAttribute("aria-valuenow", percentComplete); + // If responseText is empty (null response), ignore it. + let response = null; + if (xhr.responseText && xhr.responseText.trim().length > 0) { + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + console.warn("Failed to parse server response:", e); + } + } - if (currentChunk < totalChunks) { - // If chunks remain, keep uploading - if (currentChunk === totalChunks - 1 && document.getElementById("password").value.trim()) { - document.getElementById("uploadStatus").innerText = "Upload complete. Encrypting..."; - } - uploadNextChunk(); + currentChunk++; + const percentComplete = (currentChunk / totalChunks) * 100; + progressBar.style.width = percentComplete + "%"; + progressBar.setAttribute("aria-valuenow", percentComplete); + + if (currentChunk < totalChunks) { + // Continue uploading remaining chunks. + if (currentChunk === totalChunks - 1 && document.getElementById("password").value.trim()) { + document.getElementById("uploadStatus").innerText = "Upload complete. Encrypting..."; + } + uploadNextChunk(); + } else { + // Final chunk response handling. + document.getElementById("uploadStatus").innerText = "Upload complete."; + if (response && response.uuid) { + window.location.href = "/file/" + response.uuid; } else { - // Final chunk: check response - document.getElementById("uploadStatus").innerText = "Upload complete."; - if (response.uuid) { - window.location.href = "/file/" + response.uuid; - } else { - showMessage("danger", "Upload finished but no UUID returned from server."); - } + // No file entity returned; warn the user. + showMessage("warning", "Upload finished but no file information was returned from the server."); isUploading = false; } - } catch (err) { - console.error(err); - showMessage("danger", "Unexpected server response. Please try again."); - resetUploadUI(); } } else { console.error("Upload error:", xhr.responseText); @@ -131,11 +136,11 @@ function startChunkUpload() { xhr.send(formData); } - // Begin + // Begin the upload process. uploadNextChunk(); } -function buildChunkFormData(chunk, chunkNumber, fileName, totalChunks) { +function buildChunkFormData(chunk, chunkNumber, fileName, totalChunks, fileSize) { const uploadForm = document.getElementById("uploadForm"); const formData = new FormData(); @@ -144,6 +149,7 @@ function buildChunkFormData(chunk, chunkNumber, fileName, totalChunks) { formData.append("fileName", fileName); formData.append("chunkNumber", chunkNumber); formData.append("totalChunks", totalChunks); + formData.append("fileSize", fileSize); // Keep Indefinitely + hidden const keepIndefinitelyCheckbox = document.getElementById("keepIndefinitely"); diff --git a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java b/src/test/java/org/rostislav/quickdrop/FileServiceTests.java deleted file mode 100644 index 673830b..0000000 --- a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java +++ /dev/null @@ -1,157 +0,0 @@ -package org.rostislav.quickdrop; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; -import org.rostislav.quickdrop.entity.FileEntity; -import org.rostislav.quickdrop.model.FileUploadRequest; -import org.rostislav.quickdrop.repository.FileRepository; -import org.rostislav.quickdrop.service.FileService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.rostislav.quickdrop.TestDataContainer.*; - -@SpringBootTest -@ExtendWith(MockitoExtension.class) -@Disabled -public class FileServiceTests { - @Nested - class SaveFileTests { - @Autowired - FileService fileService; - @MockBean - FileRepository fileRepository; - @MockBean - PasswordEncoder passwordEncoder; - @Value("${file.save.path}") - private String fileSavePath; - - @AfterEach - void tearDown() { - //Delete the all files in the fileSavePath - try { - Files.walk(Path.of(fileSavePath)) - .filter(Files::isRegularFile) - .forEach(file -> { - try { - Files.delete(file); - } catch ( - IOException e) { - e.printStackTrace(); - } - }); - } catch ( - IOException e) { - e.printStackTrace(); - } - } - - // Successfully saves an unencrypted file when no password is provided - @Test - void test_save_unencrypted_file_without_password() { - File file = mock(File.class); - - FileEntity fileEntity = getFileEntity(); - fileEntity.passwordHash = null; - when(fileRepository.save(any(FileEntity.class))).thenReturn(fileEntity); - - FileEntity result = fileService.saveFile(file, getFileUploadRequest()); - - assertNotNull(result); - assertEquals("test.txt", result.name); - assertEquals("Test description", result.description); - assertEquals(1024L, result.size); - assertNull(result.passwordHash); - } - - // Successfully saves an encrypted file when a password is provided - @Test - void test_save_encrypted_file_with_password() { - File file = mock(File.class); - - FileEntity fileEntity = getFileEntity(); - when(passwordEncoder.encode(anyString())).thenReturn(fileEntity.passwordHash); - when(fileRepository.save(any(FileEntity.class))).thenReturn(fileEntity); - - FileEntity result = fileService.saveFile(file, getFileUploadRequest()); - - assertNotNull(result); - assertEquals("test.txt", result.name); - assertEquals("Test description", result.description); - assertEquals(1024L, result.size); - assertNotNull(result.passwordHash); - } - - // Correctly encodes password when provided - @Test - void test_correctly_encodes_password_when_provided() { - File file = mock(File.class); - - FileEntity fileEntity = getFileEntity(); - when(passwordEncoder.encode("securePassword")).thenReturn(fileEntity.passwordHash); - when(fileRepository.save(any(FileEntity.class))).thenReturn(fileEntity); - - FileEntity result = fileService.saveFile(file, getFileUploadRequest()); - - assertNotNull(result); - assertEquals(fileEntity.passwordHash, result.passwordHash); - } - - @Test - void test_handles_empty_file_upload_request_gracefully() { - File file = mock(File.class); - - when(fileRepository.save(any(FileEntity.class))).thenAnswer(invocation -> { - FileEntity fileEntity = invocation.getArgument(0); - fileEntity.id = 1L; // Simulate database assigning an ID - return fileEntity; - }); - - FileEntity result = fileService.saveFile(file, getEmptyFileUploadRequest()); - - assertNotNull(result); - assertEquals("test.txt", result.name); - assertNull(result.description); // Changed to match the empty request - assertEquals(1024L, result.size); - assertNull(result.passwordHash); - } - - @Test - void test_handles_null_file_upload_request() { - File file = mock(File.class); - FileUploadRequest fileUploadRequest = null; - - when(fileRepository.save(any(FileEntity.class))).thenReturn(getFileEntity()); - - FileEntity result = fileService.saveFile(file, fileUploadRequest); - - assertNull(result); - } - - @Test - void test_handle_null_or_empty_multipartfile() { - File file = mock(File.class); - - FileEntity result = fileService.saveFile(file, getFileUploadRequest()); - - assertNull(result); - } - } -} \ No newline at end of file diff --git a/src/test/java/org/rostislav/quickdrop/QuickdropApplicationTests.java b/src/test/java/org/rostislav/quickdrop/QuickdropApplicationTests.java deleted file mode 100644 index 554fdac..0000000 --- a/src/test/java/org/rostislav/quickdrop/QuickdropApplicationTests.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.rostislav.quickdrop; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -@Disabled -class QuickdropApplicationTests { - - @Test - void contextLoads() { - } -} diff --git a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java b/src/test/java/org/rostislav/quickdrop/TestDataContainer.java deleted file mode 100644 index c609a62..0000000 --- a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.rostislav.quickdrop; - -import org.rostislav.quickdrop.entity.FileEntity; -import org.rostislav.quickdrop.model.FileUploadRequest; - -import java.util.UUID; - -public class TestDataContainer { - - public static FileEntity getFileEntity() { - FileEntity fileEntity = new FileEntity(); - fileEntity.name = "test.txt"; - fileEntity.uuid = UUID.randomUUID().toString(); - fileEntity.description = "Test description"; - fileEntity.size = 1024L; - fileEntity.keepIndefinitely = false; - fileEntity.passwordHash = "hashed-password"; - return fileEntity; - } - - public static FileEntity getEmptyFileEntity() { - return new FileEntity(); - } - - public static FileUploadRequest getFileUploadRequest() { - return new FileUploadRequest("Test description", false, "password123", false); - } - - public static FileUploadRequest getEmptyFileUploadRequest() { - return new FileUploadRequest(); - } -} From 7a85c1641d34279c9f27f643a70f3df4c50b5afb Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sat, 29 Mar 2025 21:18:16 +0200 Subject: [PATCH 2/4] updating the formatting and upping versions on some dependencies --- pom.xml | 199 ++++++++++++++------------------------------------------ 1 file changed, 50 insertions(+), 149 deletions(-) diff --git a/pom.xml b/pom.xml index 7f5612b..7b6cd2e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,36 +3,18 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> - - 4.0.0 - + 4.0.0 - - org.springframework.boot - - - spring-boot-starter-parent - - - 3.3.10 - + org.springframework.boot + spring-boot-starter-parent + 3.3.10 - - org.rostislav - - - quickdrop - - - 0.0.1-SNAPSHOT - - - quickdrop - - - quickdrop - + org.rostislav + quickdrop + 0.0.1-SNAPSHOT + quickdrop + quickdrop @@ -47,147 +29,75 @@ - - 21 - - - 21 - + 21 + 21 - - org.springframework.boot - - - spring-boot-starter-data-jpa - + org.springframework.boot + spring-boot-starter-data-jpa - - org.springframework.boot - - - spring-boot-starter-security - + org.springframework.boot + spring-boot-starter-security - - org.springframework.boot - - - spring-boot-starter-thymeleaf - + org.springframework.boot + spring-boot-starter-thymeleaf - - org.springframework.boot - - - spring-boot-starter-web - + org.springframework.boot + spring-boot-starter-web - - org.thymeleaf.extras - - - thymeleaf-extras-springsecurity6 - + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 - - org.springframework.boot - - - spring-boot-starter-logging - + org.springframework.boot + spring-boot-starter-logging org.flywaydb flyway-core - - org.springframework.boot - - - spring-boot-starter-test - - - test - + org.springframework.boot + spring-boot-starter-test + test - - junit - - - junit - + junit + junit - - org.springframework.security - - - spring-security-test - - - test - + org.springframework.security + spring-security-test + test - - org.xerial - - - sqlite-jdbc - - - 3.46.0.0 - + org.xerial + sqlite-jdbc + 3.47.0.0 - - org.hibernate - - - hibernate-community-dialects - - - 6.5.2.Final - + org.hibernate + hibernate-community-dialects + 6.6.1.Final - - org.junit.jupiter - - - junit-jupiter-engine - - - 5.10.0 - - - test - + org.junit.jupiter + junit-jupiter-engine + 5.11.3 + test - - org.junit.jupiter - - - junit-jupiter-api - - - 5.10.0 - - - test - + org.junit.jupiter + junit-jupiter-api + 5.11.3 + test org.springframework.boot @@ -203,21 +113,13 @@ - - org.springframework.boot - - - spring-boot-maven-plugin - + org.springframework.boot + spring-boot-maven-plugin - - junit - - - junit - + junit + junit @@ -225,7 +127,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.13.0 21 21 @@ -233,5 +135,4 @@ - From 4f076628038b9a62a88cea1e16df6f85198ceb7d Mon Sep 17 00:00:00 2001 From: Rostislav <47450671+RoastSlav@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:03:13 +0300 Subject: [PATCH 3/4] allow longer async request (#38) --- src/main/resources/application.properties | 3 ++- src/test/resources/application-test.properties | 4 +++- src/test/resources/application.properties | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d23f8a5..bb1b75d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,6 +9,7 @@ spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 +spring.mvc.async.request-timeout=3600000 logging.file.name=log/quickdrop.log server.error.path=/error #logging.level.org.springframework=DEBUG @@ -17,4 +18,4 @@ server.error.path=/error spring.flyway.baseline-on-migrate=true spring.flyway.baseline-version=1 spring.flyway.locations=classpath:db/migration -app.version=1.4.3 \ No newline at end of file +app.version=1.4.4 diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 15e68a8..c104c70 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,3 +1,5 @@ logging.file.path=quickdrop.log spring.datasource.url=jdbc:sqlite:quickdrop.db -file.save.path=test-path \ No newline at end of file +file.save.path=test-path +spring.mvc.async.request-timeout=3600000 + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index e25d06a..c651d58 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -9,6 +9,7 @@ spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 +spring.mvc.async.request-timeout=3600000 logging.file.name=log/quickdrop.log #logging.level.org.springframework=DEBUG -#logging.level.org.hibernate=DEBUG \ No newline at end of file +#logging.level.org.hibernate=DEBUG From 2af9171ba09f17ae321395258e4241b898624db6 Mon Sep 17 00:00:00 2001 From: Rostislav <47450671+RoastSlav@users.noreply.github.com> Date: Wed, 25 Jun 2025 19:20:43 +0300 Subject: [PATCH 4/4] Add default home page setting (#39) * Add setting to choose default home page * Bump version to 1.4.5 --- .../quickdrop/controller/IndexViewController.java | 11 ++++++----- .../quickdrop/entity/ApplicationSettingsEntity.java | 10 ++++++++++ .../quickdrop/model/ApplicationSettingsViewModel.java | 10 ++++++++++ .../quickdrop/service/ApplicationSettingsService.java | 6 ++++++ src/main/resources/application.properties | 2 +- .../db/migration/V5__Add_default_home_page.sql | 2 ++ src/main/resources/templates/fileView.html | 2 +- src/main/resources/templates/listFiles.html | 4 ++-- src/main/resources/templates/settings.html | 9 +++++++++ 9 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/db/migration/V5__Add_default_home_page.sql diff --git a/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java b/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java index 843d79f..34e40cb 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java @@ -2,7 +2,6 @@ package org.rostislav.quickdrop.controller; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller @@ -14,10 +13,12 @@ public class IndexViewController { } @GetMapping("/") - public String getIndexPage(Model model) { - model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); - model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); - return "upload"; + public String getIndexPage() { + String home = applicationSettingsService.getDefaultHomePage(); + if ("list".equalsIgnoreCase(home) && applicationSettingsService.isFileListPageEnabled()) { + return "redirect:/file/list"; + } + return "redirect:/file/upload"; } @GetMapping("/error") diff --git a/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java index 93133a6..9e6226c 100644 --- a/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java @@ -23,6 +23,7 @@ public class ApplicationSettingsEntity { private boolean isFileListPageEnabled; private boolean isAdminDashboardButtonEnabled; private boolean disableEncryption; + private String defaultHomePage; public ApplicationSettingsEntity() { } @@ -38,6 +39,7 @@ public class ApplicationSettingsEntity { this.isFileListPageEnabled = settings.isFileListPageEnabled(); this.isAdminDashboardButtonEnabled = settings.isAdminDashboardButtonEnabled(); this.disableEncryption = settings.isEncryptionDisabled(); + this.defaultHomePage = settings.getDefaultHomePage(); } public long getMaxFileSize() { @@ -143,4 +145,12 @@ public class ApplicationSettingsEntity { public void setDisableEncryption(boolean disableEncryption) { this.disableEncryption = disableEncryption; } + + public String getDefaultHomePage() { + return defaultHomePage; + } + + public void setDefaultHomePage(String defaultHomePage) { + this.defaultHomePage = defaultHomePage; + } } diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java index 89fc68e..d979f91 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java @@ -16,6 +16,7 @@ public class ApplicationSettingsViewModel { private boolean isFileListPageEnabled; private boolean isAdminDashboardButtonEnabled; private boolean encryptionDisabled; + private String defaultHomePage; public ApplicationSettingsViewModel() { } @@ -32,6 +33,7 @@ public class ApplicationSettingsViewModel { this.isFileListPageEnabled = settings.isFileListPageEnabled(); this.isAdminDashboardButtonEnabled = settings.isAdminDashboardButtonEnabled(); this.encryptionDisabled = settings.isDisableEncryption(); + this.defaultHomePage = settings.getDefaultHomePage(); } public Long getId() { @@ -129,4 +131,12 @@ public class ApplicationSettingsViewModel { public void setEncryptionDisabled(boolean encryptionDisabled) { this.encryptionDisabled = encryptionDisabled; } + + public String getDefaultHomePage() { + return defaultHomePage; + } + + public void setDefaultHomePage(String defaultHomePage) { + this.defaultHomePage = defaultHomePage; + } } diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 5735021..989cd59 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -37,6 +37,7 @@ public class ApplicationSettingsService { settings.setFileListPageEnabled(true); settings.setAdminDashboardButtonEnabled(true); settings.setDisableEncryption(false); + settings.setDefaultHomePage("upload"); settings = applicationSettingsRepository.save(settings); scheduleService.updateSchedule(settings.getFileDeletionCron(), settings.getMaxFileLifeTime()); return settings; @@ -58,6 +59,7 @@ public class ApplicationSettingsService { applicationSettingsEntity.setFileListPageEnabled(settings.isFileListPageEnabled()); applicationSettingsEntity.setAdminDashboardButtonEnabled(settings.isAdminDashboardButtonEnabled()); applicationSettingsEntity.setDisableEncryption(settings.isEncryptionDisabled()); + applicationSettingsEntity.setDefaultHomePage(settings.getDefaultHomePage()); if (appPassword != null && !appPassword.isEmpty()) { applicationSettingsEntity.setAppPasswordEnabled(settings.isAppPasswordEnabled()); @@ -141,4 +143,8 @@ public class ApplicationSettingsService { public boolean isEncryptionEnabled() { return !applicationSettings.isDisableEncryption(); } + + public String getDefaultHomePage() { + return applicationSettings.getDefaultHomePage(); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bb1b75d..c698f7e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,4 +18,4 @@ server.error.path=/error spring.flyway.baseline-on-migrate=true spring.flyway.baseline-version=1 spring.flyway.locations=classpath:db/migration -app.version=1.4.4 +app.version=1.4.5 diff --git a/src/main/resources/db/migration/V5__Add_default_home_page.sql b/src/main/resources/db/migration/V5__Add_default_home_page.sql new file mode 100644 index 0000000..6029a4d --- /dev/null +++ b/src/main/resources/db/migration/V5__Add_default_home_page.sql @@ -0,0 +1,2 @@ +ALTER TABLE application_settings_entity + ADD COLUMN default_home_page VARCHAR(255) NOT NULL DEFAULT 'upload'; diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index 3317152..7026e30 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -43,7 +43,7 @@ View Files