From 860dfd062eb3ce543935c4198efa96dc8f59ebe7 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 25 Feb 2026 12:44:20 +0100 Subject: [PATCH] more htmx --- .../gtransfer/config/ConfigRuntimeHints.java | 5 + .../gtransfer/config/DefaultConfig.java | 7 + .../gtransfer/config/types/Config.java | 2 + .../gtransfer/config/types/UploadConfig.java | 12 ++ .../controller/UploadController.java | 42 +++++- Backend/src/main/resources/static/crypto.js | 28 ++++ Backend/src/main/resources/static/upload.js | 138 +++++++----------- .../src/main/resources/templates/index.html | 51 +------ .../resources/templates/upload/options.html | 27 ++++ .../resources/templates/upload/result.html | 17 +++ 10 files changed, 191 insertions(+), 138 deletions(-) create mode 100644 Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/UploadConfig.java create mode 100644 Backend/src/main/resources/static/crypto.js create mode 100644 Backend/src/main/resources/templates/upload/options.html create mode 100644 Backend/src/main/resources/templates/upload/result.html diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/ConfigRuntimeHints.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/ConfigRuntimeHints.java index e1acb05..94fe489 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/ConfigRuntimeHints.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/ConfigRuntimeHints.java @@ -50,6 +50,11 @@ public class ConfigRuntimeHints implements RuntimeHintsRegistrar { MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.ACCESS_PUBLIC_FIELDS); + hints.reflection().registerType(UploadConfig.class, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.ACCESS_DECLARED_FIELDS, + MemberCategory.ACCESS_PUBLIC_FIELDS); hints.reflection().registerType(TypeAdapter.class, MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.ACCESS_PUBLIC_FIELDS); diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/DefaultConfig.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/DefaultConfig.java index c47322e..e3c96c8 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/DefaultConfig.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/DefaultConfig.java @@ -10,6 +10,7 @@ import com.gregor_lohaus.gtransfer.config.types.ServletConfig; import com.gregor_lohaus.gtransfer.config.types.SpringConfig; import com.gregor_lohaus.gtransfer.config.types.StorageService; import com.gregor_lohaus.gtransfer.config.types.StorageServiceType; +import com.gregor_lohaus.gtransfer.config.types.UploadConfig; public class DefaultConfig { public static final Config config; @@ -42,6 +43,12 @@ public class DefaultConfig { sc.servletConfig = svc; c.springConfig = sc; + + UploadConfig uc = new UploadConfig(); + uc.maxDownloadLimit = 100; + uc.maxExpiryDays = 30; + c.uploadConfig = uc; + config = c; } } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/Config.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/Config.java index f7b7b32..f5f3284 100644 --- a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/Config.java +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/Config.java @@ -11,4 +11,6 @@ public class Config implements TomlSerializable { public SpringConfig springConfig; @Nested(name = "storageService") public StorageService storageService; + @Nested(name = "upload") + public UploadConfig uploadConfig; } diff --git a/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/UploadConfig.java b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/UploadConfig.java new file mode 100644 index 0000000..61251b6 --- /dev/null +++ b/Backend/src/main/java/com/gregor_lohaus/gtransfer/config/types/UploadConfig.java @@ -0,0 +1,12 @@ +package com.gregor_lohaus.gtransfer.config.types; + +import com.gregor_lohaus.gtransfer.config.annotations.Property; + +import io.github.wasabithumb.jtoml.serial.TomlSerializable; + +public class UploadConfig implements TomlSerializable { + @Property(name = "maxDownloadLimit") + public Integer maxDownloadLimit; + @Property(name = "maxExpiryDays") + public Integer maxExpiryDays; +} 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 cf228cf..4760da0 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 @@ -1,37 +1,63 @@ package com.gregor_lohaus.gtransfer.controller; import java.io.IOException; -import java.util.Map; +import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; 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; -@RestController +@Controller public class UploadController { + @Value("${gtransfer-config.upload.maxDownloadLimit:100}") + private Integer maxDownloadLimit; + + @Value("${gtransfer-config.upload.maxExpiryDays:30}") + private Integer maxExpiryDays; + @Autowired private AbstractStorageService storageService; @Autowired private FileRepository fileRepository; + @GetMapping("/upload/options") + public String options(@RequestParam String name, Model model) { + model.addAttribute("name", name); + model.addAttribute("maxExpiryDays", maxExpiryDays); + model.addAttribute("maxDownloadLimit", maxDownloadLimit); + return "upload/options :: form"; + } + @PostMapping("/upload") - public ResponseEntity> upload( + public String upload( @RequestParam("file") MultipartFile file, @RequestParam("hash") String hash, - @RequestParam("name") String name) throws IOException { + @RequestParam("name") String name, + @RequestParam(required = false) Integer expiryDays, + @RequestParam(required = false) Integer downloadLimit, + Model model) throws IOException { storageService.put(hash, file.getBytes()); - fileRepository.save(new File(hash, hash, name, null)); - return ResponseEntity.ok(Map.of("id", hash)); + 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.setDownloadLimit(limit); + fileRepository.save(f); + + model.addAttribute("id", hash); + return "upload/result :: view"; } } diff --git a/Backend/src/main/resources/static/crypto.js b/Backend/src/main/resources/static/crypto.js new file mode 100644 index 0000000..631b0ae --- /dev/null +++ b/Backend/src/main/resources/static/crypto.js @@ -0,0 +1,28 @@ +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 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', rawKey))) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + // Base64url-encode key for URL fragment + const base64urlKey = btoa(String.fromCharCode(...new Uint8Array(rawKey))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + + return { payload, hash, base64urlKey }; +} diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index c415450..59c0a1f 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -1,28 +1,17 @@ const dropZone = document.getElementById('drop-zone'); const fileInput = document.getElementById('file-input'); - -const views = { - prompt: document.getElementById('view-prompt'), - selected: document.getElementById('view-selected'), - uploading: document.getElementById('view-uploading'), - result: document.getElementById('view-result'), -}; +const promptHtml = dropZone.innerHTML; let selectedFile = null; -function showView(name) { - Object.entries(views).forEach(([key, el]) => el.classList.toggle('d-none', key !== name)); -} - // ── File selection ──────────────────────────────────────────────────────────── dropZone.addEventListener('click', () => { - if (views.prompt.classList.contains('d-none')) return; - fileInput.click(); + if (selectedFile === null) fileInput.click(); }); fileInput.addEventListener('change', e => { - if (e.target.files[0]) selectFile(e.target.files[0]); + if (e.target.files[0]) onFileSelected(e.target.files[0]); }); dropZone.addEventListener('dragover', e => { @@ -35,101 +24,78 @@ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); - if (e.dataTransfer.files[0]) selectFile(e.dataTransfer.files[0]); + if (e.dataTransfer.files[0] && selectedFile === null) onFileSelected(e.dataTransfer.files[0]); }); -function selectFile(file) { +function onFileSelected(file) { selectedFile = file; - document.getElementById('selected-name').textContent = file.name; - showView('selected'); + // Use htmx to fetch the options form — server renders max values from config + htmx.ajax('GET', '/upload/options?name=' + encodeURIComponent(file.name), { + target: '#drop-zone', + swap: 'innerHTML' + }); } -document.getElementById('reset-btn').addEventListener('click', e => { - e.stopPropagation(); - selectedFile = null; - fileInput.value = ''; - showView('prompt'); -}); +// ── Upload (called from onclick in server-rendered options form) ────────────── -document.getElementById('new-upload-btn').addEventListener('click', e => { - e.stopPropagation(); - selectedFile = null; - fileInput.value = ''; - showView('prompt'); -}); +async function startUpload() { + const expiryDays = document.getElementById('expiry-days')?.value; + const downloadLimit = document.getElementById('download-limit')?.value; -// ── Upload ──────────────────────────────────────────────────────────────────── - -document.getElementById('upload-btn').addEventListener('click', async e => { - e.stopPropagation(); - await upload(); -}); - -function setStatus(msg) { - document.getElementById('upload-status').textContent = msg; -} - -async function upload() { - const file = selectedFile; - showView('uploading'); + dropZone.innerHTML = ` +
+
+ Loading\u2026 +
+
+
Encrypting\u2026
`; try { - setStatus('Generating encryption key\u2026'); - const key = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - - setStatus('Encrypting\u2026'); - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - await file.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) - const rawKey = await crypto.subtle.exportKey('raw', key); - const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', rawKey))) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); - - // Base64url-encode key for URL fragment - const base64urlKey = btoa(String.fromCharCode(...new Uint8Array(rawKey))) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const { payload, hash, base64urlKey } = await encryptFile(await selectedFile.arrayBuffer()); setStatus('Uploading\u2026'); + const formData = new FormData(); - formData.append('file', new Blob([payload]), file.name); + formData.append('file', new Blob([payload]), selectedFile.name); formData.append('hash', hash); - formData.append('name', file.name); + formData.append('name', selectedFile.name); + if (expiryDays) formData.append('expiryDays', expiryDays); + if (downloadLimit) formData.append('downloadLimit', downloadLimit); const response = await fetch('/upload', { method: 'POST', body: formData }); - if (!response.ok) throw new Error(`Server responded with ${response.status}`); + if (!response.ok) throw new Error(`Server error ${response.status}`); - const { id } = await response.json(); - document.getElementById('share-link').value = - `${window.location.origin}/download/${id}#${base64urlKey}`; - showView('result'); + // Server returns HTML fragment; append key fragment client-side + dropZone.innerHTML = await response.text(); + htmx.process(dropZone); + document.getElementById('share-link').value += '#' + base64urlKey; } catch (err) { - setStatus(`Error: ${err.message}`); + dropZone.innerHTML = ` +
+
${err.message}
+ `; } } -// ── Copy link ───────────────────────────────────────────────────────────────── +function setStatus(msg) { + const el = document.getElementById('upload-status'); + if (el) el.textContent = msg; +} -document.getElementById('copy-btn').addEventListener('click', async e => { - e.stopPropagation(); +// ── Reset (called from onclick in server-rendered fragments) ────────────────── + +function resetUpload() { + selectedFile = null; + fileInput.value = ''; + dropZone.innerHTML = promptHtml; +} + +// ── Copy link (called from onclick in result fragment) ──────────────────────── + +async function copyLink() { await navigator.clipboard.writeText(document.getElementById('share-link').value); const btn = document.getElementById('copy-btn'); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); -}); +} diff --git a/Backend/src/main/resources/templates/index.html b/Backend/src/main/resources/templates/index.html index a3e1804..0e74d99 100644 --- a/Backend/src/main/resources/templates/index.html +++ b/Backend/src/main/resources/templates/index.html @@ -8,6 +8,7 @@ + @@ -27,52 +28,14 @@

+
- - -
-
📂
-
- Choose a file - or drag and drop here -
-
Any file type · Up to 10GB
+
📂
+
+ Choose a file + or drag and drop here
- - -
-
📄
-
-
- - -
-
- - -
-
-
- Loading... -
-
-
Preparing…
-
- - -
-
-
Your file is ready to share
-
- - -
- -
- - +
Any file type · Up to 10GB
diff --git a/Backend/src/main/resources/templates/upload/options.html b/Backend/src/main/resources/templates/upload/options.html new file mode 100644 index 0000000..43dc8ee --- /dev/null +++ b/Backend/src/main/resources/templates/upload/options.html @@ -0,0 +1,27 @@ + + + +
+
📄
+
filename
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + diff --git a/Backend/src/main/resources/templates/upload/result.html b/Backend/src/main/resources/templates/upload/result.html new file mode 100644 index 0000000..f623ee4 --- /dev/null +++ b/Backend/src/main/resources/templates/upload/result.html @@ -0,0 +1,17 @@ + + + +
+
+
Your file is ready to share
+
+ + +
+ +
+ +