From af02e24b4e31185661e78b67c7f755c93b7653b4 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 5 Jun 2026 14:06:01 +0200 Subject: [PATCH 1/3] chunking --- .../controller/DownloadController.java | 157 +++++++++++++++--- .../controller/UploadController.java | 38 ++++- .../gregor_lohaus/gtransfer/model/File.java | 17 ++ .../filecleanup/FileCleanupService.java | 14 +- .../filewriter/LocalStorageService.java | 8 +- .../services/filewriter/StorageKeys.java | 9 + Backend/src/main/resources/static/crypto.js | 34 ++++ Backend/src/main/resources/static/download.js | 63 +++++-- Backend/src/main/resources/static/upload.js | 49 ++++-- .../resources/templates/download/page.html | 5 +- 10 files changed, 343 insertions(+), 51 deletions(-) create mode 100644 Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/StorageKeys.java diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java index b33baed..d6b6864 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java @@ -1,6 +1,7 @@ package com.gregor_lohaus.gtransfer.controller; import java.time.LocalDateTime; +import java.util.Map; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; @@ -12,11 +13,13 @@ import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import com.gregor_lohaus.gtransfer.model.File; import com.gregor_lohaus.gtransfer.model.FileRepository; import com.gregor_lohaus.gtransfer.services.filewriter.AbstractStorageService; +import com.gregor_lohaus.gtransfer.services.filewriter.StorageKeys; @Controller public class DownloadController { @@ -32,28 +35,78 @@ public class DownloadController { return "download/page"; } + @GetMapping("/download/{id}/metadata") + @ResponseBody + @Transactional + public ResponseEntity> metadata(@PathVariable String id) { + AvailableFile available = getAvailableFile(id); + if (!available.found()) { + return ResponseEntity.status(available.status()).build(); + } + + File file = available.file(); + return ResponseEntity.ok(Map.of( + "name", file.getName(), + "chunkCount", file.getChunkCount(), + "size", file.getSize() == null ? 0 : file.getSize())); + } + + @GetMapping("/download/{id}/chunk/{index}") + @ResponseBody + @Transactional + public ResponseEntity chunk(@PathVariable String id, @PathVariable Integer index) { + AvailableFile available = getAvailableFile(id); + if (!available.found()) { + return ResponseEntity.status(available.status()).build(); + } + + File file = available.file(); + if (index == null || index < 0 || index >= file.getChunkCount()) { + return ResponseEntity.badRequest().build(); + } + + Optional data = storageService.get(storageKey(file, index)); + if (data.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(data.get()); + } + + @PostMapping("/download/{id}/complete") + @ResponseBody + @Transactional + public ResponseEntity complete(@PathVariable String id) { + AvailableFile available = getAvailableFile(id); + if (!available.found()) { + return ResponseEntity.status(available.status()).build(); + } + + File file = available.file(); + file.setDownloads(file.getDownloads() + 1); + fileRepository.save(file); + + if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) { + deleteStoredFile(file); + fileRepository.delete(file); + } + + return ResponseEntity.noContent().build(); + } + @GetMapping("/download/{id}/data") @ResponseBody @Transactional public ResponseEntity data(@PathVariable String id) { - Optional fileOpt = fileRepository.findById(id); - if (fileOpt.isEmpty()) { - return ResponseEntity.notFound().build(); + AvailableFile available = getAvailableFile(id); + if (!available.found()) { + return ResponseEntity.status(available.status()).build(); } - File file = fileOpt.get(); - - // Check expiry - if (file.getExpireyDateTime() != null && LocalDateTime.now().isAfter(file.getExpireyDateTime())) { - storageService.delete(id); - fileRepository.delete(file); - return ResponseEntity.status(HttpStatus.GONE).build(); - } - - // Check download limit before serving - if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) { - storageService.delete(id); - fileRepository.delete(file); + File file = available.file(); + if (file.isChunked()) { return ResponseEntity.status(HttpStatus.GONE).build(); } @@ -62,22 +115,80 @@ public class DownloadController { return ResponseEntity.notFound().build(); } - // Increment counter file.setDownloads(file.getDownloads() + 1); fileRepository.save(file); - // Clean up if limit now reached if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) { - storageService.delete(id); + deleteStoredFile(file); fileRepository.delete(file); } - String disposition = "attachment; filename=\"" - + file.getName().replace("\\", "\\\\").replace("\"", "\\\"") + "\""; - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(file.getName())) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(data.get()); } + + private AvailableFile getAvailableFile(String id) { + Optional fileOpt = fileRepository.findById(id); + if (fileOpt.isEmpty()) { + return AvailableFile.notFound(); + } + + File file = fileOpt.get(); + if (file.getExpireyDateTime() != null && LocalDateTime.now().isAfter(file.getExpireyDateTime())) { + deleteStoredFile(file); + fileRepository.delete(file); + return AvailableFile.gone(); + } + + if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) { + deleteStoredFile(file); + fileRepository.delete(file); + return AvailableFile.gone(); + } + + return AvailableFile.ok(file); + } + + private void deleteStoredFile(File file) { + if (!file.isChunked()) { + storageService.delete(file.getId()); + return; + } + + for (int i = 0; i < file.getChunkCount(); i++) { + storageService.delete(StorageKeys.chunk(file.getId(), i)); + } + } + + private String storageKey(File file, int index) { + if (!file.isChunked()) { + return file.getId(); + } + return StorageKeys.chunk(file.getId(), index); + } + + private String contentDisposition(String filename) { + return "attachment; filename=\"" + + filename.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private record AvailableFile(File file, HttpStatus status) { + static AvailableFile ok(File file) { + return new AvailableFile(file, HttpStatus.OK); + } + + static AvailableFile notFound() { + return new AvailableFile(null, HttpStatus.NOT_FOUND); + } + + static AvailableFile gone() { + return new AvailableFile(null, HttpStatus.GONE); + } + + boolean found() { + return file != null; + } + } } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java index 7eafe80..d3f0393 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java @@ -2,11 +2,15 @@ package com.gregor_lohaus.gtransfer.controller; import java.io.IOException; import java.time.LocalDateTime; +import java.util.OptionalLong; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -15,6 +19,7 @@ import org.springframework.web.multipart.MultipartFile; import com.gregor_lohaus.gtransfer.model.File; import com.gregor_lohaus.gtransfer.model.FileRepository; import com.gregor_lohaus.gtransfer.services.filewriter.AbstractStorageService; +import com.gregor_lohaus.gtransfer.services.filewriter.StorageKeys; @Controller public class UploadController { @@ -41,21 +46,46 @@ public class UploadController { @PostMapping("/upload") public String upload( - @RequestParam("file") MultipartFile file, @RequestParam("hash") String hash, @RequestParam("name") String name, + @RequestParam("chunkCount") Integer chunkCount, + @RequestParam("size") Long size, @RequestParam(required = false) Integer expiryDays, - @RequestParam(required = false) Integer downloadLimit) throws IOException { - - storageService.put(hash, file.getBytes()); + @RequestParam(required = false) Integer downloadLimit) { + if (!isValidId(hash) || chunkCount == null || chunkCount < 1 || size == null || size < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid upload metadata"); + } int days = expiryDays != null ? Math.min(expiryDays, maxExpiryDays) : maxExpiryDays; Integer limit = downloadLimit != null ? Math.min(downloadLimit, maxDownloadLimit) : null; File f = new File(hash, hash, name, LocalDateTime.now().plusDays(days)); + f.setChunkCount(chunkCount); + f.setSize(size); f.setDownloadLimit(limit); fileRepository.save(f); return "upload/result :: view"; } + + @PostMapping("/upload/chunk") + public ResponseEntity uploadChunk( + @RequestParam("chunk") MultipartFile chunk, + @RequestParam("hash") String hash, + @RequestParam("index") Integer index) throws IOException { + if (!isValidId(hash) || index == null || index < 0) { + return ResponseEntity.badRequest().build(); + } + + OptionalLong written = storageService.put(StorageKeys.chunk(hash, index), chunk.getBytes()); + if (written.isEmpty()) { + return ResponseEntity.internalServerError().build(); + } + + return ResponseEntity.noContent().build(); + } + + private boolean isValidId(String id) { + return id != null && id.matches("[a-f0-9]{64}"); + } } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java index 8fc0227..faa9e39 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java @@ -13,6 +13,8 @@ public class File { private String id; private String path; private String name; + private Integer chunkCount; + private Long size; private LocalDateTime expireyDateTime; private Integer downloadLimit; @Column(columnDefinition = "integer default 0") @@ -41,6 +43,21 @@ public class File { public void setName(String name) { this.name = name; } + public Integer getChunkCount() { + return chunkCount == null ? 1 : chunkCount; + } + public void setChunkCount(Integer chunkCount) { + this.chunkCount = chunkCount; + } + public boolean isChunked() { + return chunkCount != null; + } + public Long getSize() { + return size; + } + public void setSize(Long size) { + this.size = size; + } public Integer getDownloadLimit() { return downloadLimit; } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java index 5a7fd21..502759a 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import com.gregor_lohaus.gtransfer.model.File; import com.gregor_lohaus.gtransfer.model.FileRepository; import com.gregor_lohaus.gtransfer.services.filewriter.AbstractStorageService; +import com.gregor_lohaus.gtransfer.services.filewriter.StorageKeys; public class FileCleanupService { private Boolean enabled; @@ -43,9 +44,20 @@ public class FileCleanupService { }; for (File file : expired) { - storageService.delete(file.getId()); + deleteStoredFile(file); fileRepository.delete(file); } log.info("Cleaned up {} expired file(s)", expired.size()); } + + private void deleteStoredFile(File file) { + if (!file.isChunked()) { + storageService.delete(file.getId()); + return; + } + + for (int i = 0; i < file.getChunkCount(); i++) { + storageService.delete(StorageKeys.chunk(file.getId(), i)); + } + } } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/LocalStorageService.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/LocalStorageService.java index 9c8c546..e30b19b 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/LocalStorageService.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/LocalStorageService.java @@ -15,8 +15,12 @@ public class LocalStorageService extends AbstractStorageService { @Override public OptionalLong put(String id, byte[] data) { try { - Files.createDirectories(root); - Files.write(root.resolve(id), data); + Path target = root.resolve(id); + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(target, data); return OptionalLong.of(data.length); } catch (IOException e) { return OptionalLong.empty(); diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/StorageKeys.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/StorageKeys.java new file mode 100644 index 0000000..5b30f33 --- /dev/null +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/StorageKeys.java @@ -0,0 +1,9 @@ +package com.gregor_lohaus.gtransfer.services.filewriter; + +public final class StorageKeys { + private StorageKeys() {} + + public static String chunk(String id, int index) { + return id + "/chunks/" + index; + } +} diff --git a/Backend/src/main/resources/static/crypto.js b/Backend/src/main/resources/static/crypto.js index 087c14a..89cb261 100644 --- a/Backend/src/main/resources/static/crypto.js +++ b/Backend/src/main/resources/static/crypto.js @@ -1,3 +1,33 @@ +var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; + +async function generateFileKey() { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + + const rawKey = await crypto.subtle.exportKey('raw', key); + const hash = await hashKey(rawKey); + const base64urlKey = encodeKey(rawKey); + + return { key, rawKey, hash, base64urlKey }; +} + +async function encryptFileChunk(file, index, key, chunkSize = DEFAULT_CHUNK_SIZE) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const plaintext = await file.slice(start, end).arrayBuffer(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); + + const payload = new Uint8Array(12 + ciphertext.byteLength); + payload.set(iv, 0); + payload.set(new Uint8Array(ciphertext), 12); + + return payload; +} + async function encryptFile(arrayBuffer) { const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, @@ -30,6 +60,10 @@ async function hashKey(rawKey) { } async function decryptFile(payload, key) { + return decryptChunk(payload, key); +} + +async function decryptChunk(payload, key) { const bytes = new Uint8Array(payload); const iv = bytes.slice(0, 12); const ciphertext = bytes.slice(12); diff --git a/Backend/src/main/resources/static/download.js b/Backend/src/main/resources/static/download.js index 2e23859..b9a06ef 100644 --- a/Backend/src/main/resources/static/download.js +++ b/Backend/src/main/resources/static/download.js @@ -11,24 +11,46 @@ if (!fragment) { 'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['decrypt'] ); - setStatus('Downloading\u2026'); - const response = await fetch('/download/' + id + '/data'); + setProgress('Loading metadata\u2026', 0, 1); + const metadataResponse = await fetch('/download/' + id + '/metadata'); - if (response.status === 410) { + if (metadataResponse.status === 410) { showError('This file has expired or reached its download limit.'); - } else if (!response.ok) { - showError(`Download failed (${response.status}).`); + } else if (!metadataResponse.ok) { + showError(`Download failed (${metadataResponse.status}).`); } else { - const disposition = response.headers.get('Content-Disposition') || ''; - const filename = disposition.match(/filename="?([^"]+)"?/)?.[1] || 'download'; + const metadata = await metadataResponse.json(); + const filename = metadata.name || 'download'; + const chunkCount = metadata.chunkCount || 1; + const chunks = []; - setStatus('Decrypting\u2026'); - const plaintext = await decryptFile(await response.arrayBuffer(), key); + for (let index = 0; index < chunkCount; index++) { + setProgress(`Downloading chunk ${index + 1} of ${chunkCount}\u2026`, index, chunkCount); + const chunkResponse = await fetch('/download/' + id + '/chunk/' + index); + if (chunkResponse.status === 410) { + throw new Error('This file has expired or reached its download limit.'); + } + if (!chunkResponse.ok) { + throw new Error(`Chunk download failed (${chunkResponse.status})`); + } + setProgress(`Decrypting chunk ${index + 1} of ${chunkCount}\u2026`, index + 0.5, chunkCount); + const plaintextChunk = await decryptChunk(await chunkResponse.arrayBuffer(), key); + chunks.push(new Uint8Array(plaintextChunk)); + setProgress(`Downloaded ${index + 1} of ${chunkCount} chunks`, index + 1, chunkCount); + } + + const completeResponse = await fetch('/download/' + id + '/complete', { method: 'POST' }); + if (!completeResponse.ok && completeResponse.status !== 404 && completeResponse.status !== 410) { + throw new Error(`Download completion failed (${completeResponse.status})`); + } + + setProgress('Preparing preview\u2026', chunkCount, chunkCount); + const plaintext = combineChunks(chunks); showPreview(filename, plaintext); } } catch (err) { - showError('Decryption failed: ' + err.message); + showError(err.message); } function setStatus(msg) { @@ -36,6 +58,16 @@ function setStatus(msg) { if (el) el.textContent = msg; } +function setProgress(msg, completed, total) { + setStatus(msg); + const percent = Math.round((completed / total) * 100); + const bar = htmx.find('#download-progress'); + if (!bar) return; + bar.style.width = `${percent}%`; + bar.textContent = `${percent}%`; + bar.setAttribute('aria-valuenow', String(percent)); +} + function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } @@ -58,6 +90,17 @@ function getMimeType(filename) { return types[ext] || 'application/octet-stream'; } +function combineChunks(chunks) { + const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const combined = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined.buffer; +} + function showPreview(filename, plaintext) { const mimeType = getMimeType(filename); const blob = new Blob([plaintext], { type: mimeType }); diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index f70a771..3dd2a55 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -43,22 +43,41 @@ async function startUpload() { Loading\u2026 -
Encrypting\u2026
`, +
Preparing\u2026
+
+
0%
+
`, { swapStyle: 'innerHTML' }); try { - const { payload, hash, base64urlKey } = await encryptFile(await selectedFile.arrayBuffer()); + const { key, hash, base64urlKey } = await generateFileKey(); + const chunkCount = Math.max(1, Math.ceil(selectedFile.size / DEFAULT_CHUNK_SIZE)); - setStatus('Uploading\u2026'); + for (let index = 0; index < chunkCount; index++) { + setProgress(`Encrypting chunk ${index + 1} of ${chunkCount}\u2026`, index, chunkCount); + const payload = await encryptFileChunk(selectedFile, index, key); - const formData = new FormData(); - formData.append('file', new Blob([payload]), selectedFile.name); - formData.append('hash', hash); - formData.append('name', selectedFile.name); - if (expiryDays) formData.append('expiryDays', expiryDays); - if (downloadLimit) formData.append('downloadLimit', downloadLimit); + setProgress(`Uploading chunk ${index + 1} of ${chunkCount}\u2026`, index + 0.5, chunkCount); + const chunkData = new FormData(); + chunkData.append('chunk', new Blob([payload]), String(index)); + chunkData.append('hash', hash); + chunkData.append('index', index); - const response = await fetch('/upload', { method: 'POST', body: formData }); + const chunkResponse = await fetch('/upload/chunk', { method: 'POST', body: chunkData }); + if (!chunkResponse.ok) throw new Error(`Chunk upload failed (${chunkResponse.status})`); + setProgress(`Uploaded chunk ${index + 1} of ${chunkCount}`, index + 1, chunkCount); + } + + setProgress('Finalizing\u2026', chunkCount, chunkCount); + const metadata = new FormData(); + metadata.append('hash', hash); + metadata.append('name', selectedFile.name); + metadata.append('chunkCount', chunkCount); + metadata.append('size', selectedFile.size); + if (expiryDays) metadata.append('expiryDays', expiryDays); + if (downloadLimit) metadata.append('downloadLimit', downloadLimit); + + const response = await fetch('/upload', { method: 'POST', body: metadata }); if (!response.ok) throw new Error(`Server error ${response.status}`); htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' }); @@ -79,6 +98,16 @@ function setStatus(msg) { if (el) el.textContent = msg; } +function setProgress(msg, completed, total) { + setStatus(msg); + const percent = Math.round((completed / total) * 100); + const bar = htmx.find('#upload-progress'); + if (!bar) return; + bar.style.width = `${percent}%`; + bar.textContent = `${percent}%`; + bar.setAttribute('aria-valuenow', String(percent)); +} + function resetUpload() { selectedFile = null; fileInput.value = ''; diff --git a/Backend/src/main/resources/templates/download/page.html b/Backend/src/main/resources/templates/download/page.html index b42f137..dc013aa 100644 --- a/Backend/src/main/resources/templates/download/page.html +++ b/Backend/src/main/resources/templates/download/page.html @@ -20,7 +20,10 @@
🔒
-
Preparing…
+
Preparing…
+
+
0%
+
From 0c3f8353be2a1e4ebf31e25cb008245333f56dec Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 5 Jun 2026 14:29:45 +0200 Subject: [PATCH 2/3] get rid of backwards compatibility complexity --- .../controller/DownloadController.java | 53 +--------------- .../gregor_lohaus/gtransfer/model/File.java | 3 - .../filecleanup/FileCleanupService.java | 5 -- Backend/src/main/resources/static/crypto.js | 62 +++++++------------ Backend/src/main/resources/static/upload.js | 19 +++--- 5 files changed, 31 insertions(+), 111 deletions(-) diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java index d6b6864..38bfef6 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java @@ -5,7 +5,6 @@ import java.util.Map; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -65,7 +64,7 @@ public class DownloadController { return ResponseEntity.badRequest().build(); } - Optional data = storageService.get(storageKey(file, index)); + Optional data = storageService.get(StorageKeys.chunk(file.getId(), index)); if (data.isEmpty()) { return ResponseEntity.notFound().build(); } @@ -96,39 +95,6 @@ public class DownloadController { return ResponseEntity.noContent().build(); } - @GetMapping("/download/{id}/data") - @ResponseBody - @Transactional - public ResponseEntity data(@PathVariable String id) { - AvailableFile available = getAvailableFile(id); - if (!available.found()) { - return ResponseEntity.status(available.status()).build(); - } - - File file = available.file(); - if (file.isChunked()) { - return ResponseEntity.status(HttpStatus.GONE).build(); - } - - Optional data = storageService.get(id); - if (data.isEmpty()) { - return ResponseEntity.notFound().build(); - } - - file.setDownloads(file.getDownloads() + 1); - fileRepository.save(file); - - if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) { - deleteStoredFile(file); - fileRepository.delete(file); - } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(file.getName())) - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(data.get()); - } - private AvailableFile getAvailableFile(String id) { Optional fileOpt = fileRepository.findById(id); if (fileOpt.isEmpty()) { @@ -152,28 +118,11 @@ public class DownloadController { } private void deleteStoredFile(File file) { - if (!file.isChunked()) { - storageService.delete(file.getId()); - return; - } - for (int i = 0; i < file.getChunkCount(); i++) { storageService.delete(StorageKeys.chunk(file.getId(), i)); } } - private String storageKey(File file, int index) { - if (!file.isChunked()) { - return file.getId(); - } - return StorageKeys.chunk(file.getId(), index); - } - - private String contentDisposition(String filename) { - return "attachment; filename=\"" - + filename.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; - } - private record AvailableFile(File file, HttpStatus status) { static AvailableFile ok(File file) { return new AvailableFile(file, HttpStatus.OK); diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java index faa9e39..441b6d3 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java @@ -49,9 +49,6 @@ public class File { public void setChunkCount(Integer chunkCount) { this.chunkCount = chunkCount; } - public boolean isChunked() { - return chunkCount != null; - } public Long getSize() { return size; } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java index 502759a..41d0780 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filecleanup/FileCleanupService.java @@ -51,11 +51,6 @@ public class FileCleanupService { } private void deleteStoredFile(File file) { - if (!file.isChunked()) { - storageService.delete(file.getId()); - return; - } - for (int i = 0; i < file.getChunkCount(); i++) { storageService.delete(StorageKeys.chunk(file.getId(), i)); } diff --git a/Backend/src/main/resources/static/crypto.js b/Backend/src/main/resources/static/crypto.js index 89cb261..7fa15ac 100644 --- a/Backend/src/main/resources/static/crypto.js +++ b/Backend/src/main/resources/static/crypto.js @@ -1,6 +1,6 @@ var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; -async function generateFileKey() { +async function encryptFile(file, chunkSize = DEFAULT_CHUNK_SIZE) { const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, @@ -10,47 +10,31 @@ async function generateFileKey() { const rawKey = await crypto.subtle.exportKey('raw', key); const hash = await hashKey(rawKey); const base64urlKey = encodeKey(rawKey); + const chunkCount = Math.max(1, Math.ceil(file.size / chunkSize)); - return { key, rawKey, hash, base64urlKey }; + return { + hash, + base64urlKey, + chunkCount, + size: file.size, + chunks: encryptedChunks(file, key, chunkCount, chunkSize) + }; } -async function encryptFileChunk(file, index, key, chunkSize = DEFAULT_CHUNK_SIZE) { - const start = index * chunkSize; - const end = Math.min(start + chunkSize, file.size); - const plaintext = await file.slice(start, end).arrayBuffer(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); +async function* encryptedChunks(file, key, chunkCount, chunkSize) { + for (let index = 0; index < chunkCount; index++) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const plaintext = await file.slice(start, end).arrayBuffer(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); - const payload = new Uint8Array(12 + ciphertext.byteLength); - payload.set(iv, 0); - payload.set(new Uint8Array(ciphertext), 12); + const payload = new Uint8Array(12 + ciphertext.byteLength); + payload.set(iv, 0); + payload.set(new Uint8Array(ciphertext), 12); - return payload; -} - -async function encryptFile(arrayBuffer) { - const key = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, arrayBuffer); - - // Payload: 12-byte IV prepended to ciphertext - const payload = new Uint8Array(12 + ciphertext.byteLength); - payload.set(iv, 0); - payload.set(new Uint8Array(ciphertext), 12); - - // SHA-256(rawKey) → file identifier sent to server; server never sees the key itself - const rawKey = await crypto.subtle.exportKey('raw', key); - const hash = await hashKey(rawKey); - - const base64urlKey = encodeKey(rawKey); - - return { payload, hash, base64urlKey }; + yield { index, payload }; + } } async function hashKey(rawKey) { @@ -59,10 +43,6 @@ async function hashKey(rawKey) { .join(''); } -async function decryptFile(payload, key) { - return decryptChunk(payload, key); -} - async function decryptChunk(payload, key) { const bytes = new Uint8Array(payload); const iv = bytes.slice(0, 12); diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index 3dd2a55..26a6ded 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -50,17 +50,16 @@ async function startUpload() { { swapStyle: 'innerHTML' }); try { - const { key, hash, base64urlKey } = await generateFileKey(); - const chunkCount = Math.max(1, Math.ceil(selectedFile.size / DEFAULT_CHUNK_SIZE)); + const encryptedFile = await encryptFile(selectedFile); - for (let index = 0; index < chunkCount; index++) { + for await (const { index, payload } of encryptedFile.chunks) { + const chunkCount = encryptedFile.chunkCount; setProgress(`Encrypting chunk ${index + 1} of ${chunkCount}\u2026`, index, chunkCount); - const payload = await encryptFileChunk(selectedFile, index, key); setProgress(`Uploading chunk ${index + 1} of ${chunkCount}\u2026`, index + 0.5, chunkCount); const chunkData = new FormData(); chunkData.append('chunk', new Blob([payload]), String(index)); - chunkData.append('hash', hash); + chunkData.append('hash', encryptedFile.hash); chunkData.append('index', index); const chunkResponse = await fetch('/upload/chunk', { method: 'POST', body: chunkData }); @@ -68,12 +67,12 @@ async function startUpload() { setProgress(`Uploaded chunk ${index + 1} of ${chunkCount}`, index + 1, chunkCount); } - setProgress('Finalizing\u2026', chunkCount, chunkCount); + setProgress('Finalizing\u2026', encryptedFile.chunkCount, encryptedFile.chunkCount); const metadata = new FormData(); - metadata.append('hash', hash); + metadata.append('hash', encryptedFile.hash); metadata.append('name', selectedFile.name); - metadata.append('chunkCount', chunkCount); - metadata.append('size', selectedFile.size); + metadata.append('chunkCount', encryptedFile.chunkCount); + metadata.append('size', encryptedFile.size); if (expiryDays) metadata.append('expiryDays', expiryDays); if (downloadLimit) metadata.append('downloadLimit', downloadLimit); @@ -82,7 +81,7 @@ async function startUpload() { htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' }); htmx.process(dropZone); - htmx.find('#share-link').value = window.location.origin + '/download#' + base64urlKey; + htmx.find('#share-link').value = window.location.origin + '/download#' + encryptedFile.base64urlKey; } catch (err) { htmx.swap(dropZone, ` From f5e024f41cc1d696b1e2a46287b5324484d19adf Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 5 Jun 2026 14:44:12 +0200 Subject: [PATCH 3/3] remove unused size field on File model --- .../gtransfer/controller/DownloadController.java | 3 +-- .../gtransfer/controller/UploadController.java | 4 +--- .../main/java/com/gregor_lohaus/gtransfer/model/File.java | 7 ------- Backend/src/main/resources/static/crypto.js | 1 - Backend/src/main/resources/static/upload.js | 1 - 5 files changed, 2 insertions(+), 14 deletions(-) diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java index 38bfef6..5770a05 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java @@ -46,8 +46,7 @@ public class DownloadController { File file = available.file(); return ResponseEntity.ok(Map.of( "name", file.getName(), - "chunkCount", file.getChunkCount(), - "size", file.getSize() == null ? 0 : file.getSize())); + "chunkCount", file.getChunkCount())); } @GetMapping("/download/{id}/chunk/{index}") diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java index d3f0393..6bffd9f 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/UploadController.java @@ -49,10 +49,9 @@ public class UploadController { @RequestParam("hash") String hash, @RequestParam("name") String name, @RequestParam("chunkCount") Integer chunkCount, - @RequestParam("size") Long size, @RequestParam(required = false) Integer expiryDays, @RequestParam(required = false) Integer downloadLimit) { - if (!isValidId(hash) || chunkCount == null || chunkCount < 1 || size == null || size < 0) { + if (!isValidId(hash) || chunkCount == null || chunkCount < 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid upload metadata"); } @@ -61,7 +60,6 @@ public class UploadController { File f = new File(hash, hash, name, LocalDateTime.now().plusDays(days)); f.setChunkCount(chunkCount); - f.setSize(size); f.setDownloadLimit(limit); fileRepository.save(f); diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java index 441b6d3..66fb118 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/model/File.java @@ -14,7 +14,6 @@ public class File { private String path; private String name; private Integer chunkCount; - private Long size; private LocalDateTime expireyDateTime; private Integer downloadLimit; @Column(columnDefinition = "integer default 0") @@ -49,12 +48,6 @@ public class File { public void setChunkCount(Integer chunkCount) { this.chunkCount = chunkCount; } - public Long getSize() { - return size; - } - public void setSize(Long size) { - this.size = size; - } public Integer getDownloadLimit() { return downloadLimit; } diff --git a/Backend/src/main/resources/static/crypto.js b/Backend/src/main/resources/static/crypto.js index 7fa15ac..7fba924 100644 --- a/Backend/src/main/resources/static/crypto.js +++ b/Backend/src/main/resources/static/crypto.js @@ -16,7 +16,6 @@ async function encryptFile(file, chunkSize = DEFAULT_CHUNK_SIZE) { hash, base64urlKey, chunkCount, - size: file.size, chunks: encryptedChunks(file, key, chunkCount, chunkSize) }; } diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index 26a6ded..698b538 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -72,7 +72,6 @@ async function startUpload() { metadata.append('hash', encryptedFile.hash); metadata.append('name', selectedFile.name); metadata.append('chunkCount', encryptedFile.chunkCount); - metadata.append('size', encryptedFile.size); if (expiryDays) metadata.append('expiryDays', expiryDays); if (downloadLimit) metadata.append('downloadLimit', downloadLimit);