From b4e033f905f2913281615626b72a325bef27fd39 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 25 Feb 2026 13:11:11 +0100 Subject: [PATCH] working download --- .../controller/DownloadController.java | 86 ++++++++++++++++++ .../gregor_lohaus/gtransfer/model/File.java | 8 ++ .../filewriter/AbstractStorageService.java | 1 + .../filewriter/DummyStorageService.java | 5 ++ .../filewriter/LocalStorageService.java | 9 ++ Backend/src/main/resources/static/download.js | 87 +++++++++++++++++++ Backend/src/main/resources/static/upload.js | 5 +- .../resources/templates/download/page.html | 32 +++++++ 8 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java create mode 100644 Backend/src/main/resources/static/download.js create mode 100644 Backend/src/main/resources/templates/download/page.html 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 new file mode 100644 index 0000000..5bb678e --- /dev/null +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/controller/DownloadController.java @@ -0,0 +1,86 @@ +package com.gregor_lohaus.gtransfer.controller; + +import java.time.LocalDateTime; +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; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +@Controller +public class DownloadController { + + @Autowired + private FileRepository fileRepository; + + @Autowired + private AbstractStorageService storageService; + + @GetMapping("/download/{id}") + public String page(@PathVariable String id, Model model) { + fileRepository.findById(id) + .ifPresent(f -> model.addAttribute("filename", f.getName())); + return "download/page"; + } + + @GetMapping("/download/{id}/data") + @ResponseBody + @Transactional + public ResponseEntity data(@PathVariable String id) { + Optional fileOpt = fileRepository.findById(id); + if (fileOpt.isEmpty()) { + return ResponseEntity.notFound().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); + return ResponseEntity.status(HttpStatus.GONE).build(); + } + + Optional data = storageService.get(id); + if (data.isEmpty()) { + 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); + fileRepository.delete(file); + } + + String disposition = "attachment; filename=\"" + + file.getName().replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(data.get()); + } +} 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 69496e2..8fc0227 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 @@ -15,6 +15,8 @@ public class File { private String name; private LocalDateTime expireyDateTime; private Integer downloadLimit; + @Column(columnDefinition = "integer default 0") + private int downloads = 0; public LocalDateTime getExpireyDateTime() { return expireyDateTime; } @@ -45,6 +47,12 @@ public class File { public void setDownloadLimit(Integer downloadLimit) { this.downloadLimit = downloadLimit; } + public int getDownloads() { + return downloads; + } + public void setDownloads(int downloads) { + this.downloads = downloads; + } public File(String id, String path, String name, LocalDateTime expDateTime) { this.path = path; this.name = name; diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/AbstractStorageService.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/AbstractStorageService.java index 4c79ee1..d85ed1c 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/AbstractStorageService.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/AbstractStorageService.java @@ -13,4 +13,5 @@ public abstract class AbstractStorageService { abstract public OptionalLong put(String id, byte[] data); abstract public Optional get(String id); + abstract public boolean delete(String id); } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/DummyStorageService.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/DummyStorageService.java index a24399e..fca98bf 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/DummyStorageService.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/services/filewriter/DummyStorageService.java @@ -19,4 +19,9 @@ public class DummyStorageService extends AbstractStorageService { public Optional get(String id) { return Optional.empty(); } + + @Override + public boolean delete(String id) { + return false; + } } 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 9ff1cd4..9c8c546 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 @@ -33,4 +33,13 @@ public class LocalStorageService extends AbstractStorageService { return Optional.empty(); } } + + @Override + public boolean delete(String id) { + try { + return Files.deleteIfExists(root.resolve(id)); + } catch (IOException e) { + return false; + } + } } diff --git a/Backend/src/main/resources/static/download.js b/Backend/src/main/resources/static/download.js new file mode 100644 index 0000000..759b3c0 --- /dev/null +++ b/Backend/src/main/resources/static/download.js @@ -0,0 +1,87 @@ +(async function () { + const fragment = location.hash.slice(1); + if (!fragment) { + showError('No decryption key found in URL.'); + return; + } + + try { + setStatus('Deriving key\u2026'); + + // Decode base64url → raw key bytes + const base64 = fragment.replace(/-/g, '+').replace(/_/g, '/'); + const rawKeyBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + + // Import key + const key = await crypto.subtle.importKey( + 'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['decrypt'] + ); + + // Derive hash → verify it matches the file ID in the URL + const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', rawKeyBytes))) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + const pathId = location.pathname.split('/').pop(); + if (hash !== pathId) { + showError('Invalid link — key does not match file.'); + return; + } + + setStatus('Downloading\u2026'); + const response = await fetch(location.pathname + '/data'); + + if (response.status === 410) { + showError('This file has expired or reached its download limit.'); + return; + } + if (!response.ok) { + showError(`Download failed (${response.status}).`); + return; + } + + // Extract filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition') || ''; + const filename = disposition.match(/filename="?([^"]+)"?/)?.[1] || 'download'; + + setStatus('Decrypting\u2026'); + const encrypted = new Uint8Array(await response.arrayBuffer()); + const iv = encrypted.slice(0, 12); + const ciphertext = encrypted.slice(12); + + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + + // Trigger browser download + const url = URL.createObjectURL(new Blob([plaintext])); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + + showSuccess(filename); + + } catch (err) { + showError('Decryption failed: ' + err.message); + } +})(); + +function setStatus(msg) { + const el = document.getElementById('download-status'); + if (el) el.textContent = msg; +} + +function showSuccess(filename) { + document.getElementById('download-state').innerHTML = ` +
+
${filename}
+
Your download has started.
+ Send a file`; +} + +function showError(msg) { + document.getElementById('download-state').innerHTML = ` +
+
${msg}
+ Go home`; +} diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index 59c0a1f..8d77746 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -65,10 +65,11 @@ async function startUpload() { const response = await fetch('/upload', { method: 'POST', body: formData }); if (!response.ok) throw new Error(`Server error ${response.status}`); - // Server returns HTML fragment; append key fragment client-side + // Server returns HTML fragment; prepend origin and append key fragment client-side dropZone.innerHTML = await response.text(); htmx.process(dropZone); - document.getElementById('share-link').value += '#' + base64urlKey; + const shareLink = document.getElementById('share-link'); + shareLink.value = window.location.origin + shareLink.value + '#' + base64urlKey; } catch (err) { dropZone.innerHTML = ` diff --git a/Backend/src/main/resources/templates/download/page.html b/Backend/src/main/resources/templates/download/page.html new file mode 100644 index 0000000..2ab594a --- /dev/null +++ b/Backend/src/main/resources/templates/download/page.html @@ -0,0 +1,32 @@ + + + + + + GTransfer + + + + + + + + +
+
+
🔒
+
+
Preparing…
+
+
+ + + + +