From e78ebb25c34633c281dfdf7a9dac376b34766e08 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 25 Feb 2026 16:43:51 +0100 Subject: [PATCH] refactor frontend, add readme --- .../controller/DownloadController.java | 7 +- .../controller/UploadController.java | 4 +- Backend/src/main/resources/static/crypto.js | 31 ++++-- Backend/src/main/resources/static/download.js | 94 ++++++++----------- Backend/src/main/resources/static/upload.js | 55 +++++------ .../resources/templates/download/page.html | 9 +- .../resources/templates/upload/result.html | 3 +- README.md | 45 +++++++++ 8 files changed, 140 insertions(+), 108 deletions(-) create mode 100644 README.md 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 5bb678e..b33baed 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 @@ -10,7 +10,6 @@ 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; @@ -28,10 +27,8 @@ public class DownloadController { @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())); + @GetMapping("/download") + public String page() { return "download/page"; } 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 4760da0..7eafe80 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 @@ -45,8 +45,7 @@ public class UploadController { @RequestParam("hash") String hash, @RequestParam("name") String name, @RequestParam(required = false) Integer expiryDays, - @RequestParam(required = false) Integer downloadLimit, - Model model) throws IOException { + @RequestParam(required = false) Integer downloadLimit) throws IOException { storageService.put(hash, file.getBytes()); @@ -57,7 +56,6 @@ public class UploadController { 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 index 631b0ae..087c14a 100644 --- a/Backend/src/main/resources/static/crypto.js +++ b/Backend/src/main/resources/static/crypto.js @@ -16,13 +16,32 @@ async function encryptFile(arrayBuffer) { // 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(''); + const hash = await hashKey(rawKey); - // Base64url-encode key for URL fragment - const base64urlKey = btoa(String.fromCharCode(...new Uint8Array(rawKey))) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const base64urlKey = encodeKey(rawKey); return { payload, hash, base64urlKey }; } + +async function hashKey(rawKey) { + return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', rawKey))) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function decryptFile(payload, key) { + const bytes = new Uint8Array(payload); + const iv = bytes.slice(0, 12); + const ciphertext = bytes.slice(12); + return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); +} + +function encodeKey(rawKey) { + return btoa(String.fromCharCode(...new Uint8Array(rawKey))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function decodeKey(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); +} diff --git a/Backend/src/main/resources/static/download.js b/Backend/src/main/resources/static/download.js index 759b3c0..e93be04 100644 --- a/Backend/src/main/resources/static/download.js +++ b/Backend/src/main/resources/static/download.js @@ -1,87 +1,69 @@ -(async function () { - const fragment = location.hash.slice(1); - if (!fragment) { - showError('No decryption key found in URL.'); - return; - } +const fragment = location.hash.slice(1); +if (!fragment) { + showError('No decryption key found in URL.'); +} else try { + setStatus('Deriving key\u2026'); - try { - setStatus('Deriving key\u2026'); + const rawKeyBytes = decodeKey(fragment); + const id = await hashKey(rawKeyBytes); - // Decode base64url → raw key bytes - const base64 = fragment.replace(/-/g, '+').replace(/_/g, '/'); - const rawKeyBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + 'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['decrypt'] + ); - // Import key - const key = await crypto.subtle.importKey( - 'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['decrypt'] - ); + setStatus('Downloading\u2026'); + const response = await fetch('/download/' + id + '/data'); - // 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 + if (response.status === 410) { + showError('This file has expired or reached its download limit.'); + } else if (!response.ok) { + showError(`Download failed (${response.status}).`); + } else { 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 decryptFile(await response.arrayBuffer(), key); - 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); + htmx.swap(htmx.find('#download-state'), ` +
🔒
+
${filename}
+ `, + { swapStyle: 'innerHTML' }); - } catch (err) { - showError('Decryption failed: ' + err.message); + htmx.on(htmx.find('#download-btn'), 'click', () => { + a.click(); + URL.revokeObjectURL(url); + showSuccess(filename); + }); } -})(); +} catch (err) { + showError('Decryption failed: ' + err.message); +} function setStatus(msg) { - const el = document.getElementById('download-status'); + const el = htmx.find('#download-status'); if (el) el.textContent = msg; } function showSuccess(filename) { - document.getElementById('download-state').innerHTML = ` + htmx.swap(htmx.find('#download-state'), `
${filename}
Your download has started.
- Send a file`; + Send a file`, + { swapStyle: 'innerHTML' }); } function showError(msg) { - document.getElementById('download-state').innerHTML = ` + htmx.swap(htmx.find('#download-state'), `
${msg}
- Go home`; + Go home`, + { swapStyle: 'innerHTML' }); } diff --git a/Backend/src/main/resources/static/upload.js b/Backend/src/main/resources/static/upload.js index 8d77746..f70a771 100644 --- a/Backend/src/main/resources/static/upload.js +++ b/Backend/src/main/resources/static/upload.js @@ -1,54 +1,50 @@ -const dropZone = document.getElementById('drop-zone'); -const fileInput = document.getElementById('file-input'); +const dropZone = htmx.find('#drop-zone'); +const fileInput = htmx.find('#file-input'); const promptHtml = dropZone.innerHTML; let selectedFile = null; -// ── File selection ──────────────────────────────────────────────────────────── - -dropZone.addEventListener('click', () => { +htmx.on(dropZone, 'click', () => { if (selectedFile === null) fileInput.click(); }); -fileInput.addEventListener('change', e => { +htmx.on(fileInput, 'change', e => { if (e.target.files[0]) onFileSelected(e.target.files[0]); }); -dropZone.addEventListener('dragover', e => { +htmx.on(dropZone, 'dragover', e => { e.preventDefault(); - dropZone.classList.add('dragover'); + htmx.addClass(dropZone, 'dragover'); }); -dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); +htmx.on(dropZone, 'dragleave', () => htmx.removeClass(dropZone, 'dragover')); -dropZone.addEventListener('drop', e => { +htmx.on(dropZone, 'drop', e => { e.preventDefault(); - dropZone.classList.remove('dragover'); + htmx.removeClass(dropZone, 'dragover'); if (e.dataTransfer.files[0] && selectedFile === null) onFileSelected(e.dataTransfer.files[0]); }); function onFileSelected(file) { selectedFile = file; - // 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' }); } -// ── Upload (called from onclick in server-rendered options form) ────────────── - async function startUpload() { - const expiryDays = document.getElementById('expiry-days')?.value; - const downloadLimit = document.getElementById('download-limit')?.value; + const expiryDays = htmx.find('#expiry-days')?.value; + const downloadLimit = htmx.find('#download-limit')?.value; - dropZone.innerHTML = ` + htmx.swap(dropZone, `
Loading\u2026
-
Encrypting\u2026
`; +
Encrypting\u2026
`, + { swapStyle: 'innerHTML' }); try { const { payload, hash, base64urlKey } = await encryptFile(await selectedFile.arrayBuffer()); @@ -65,38 +61,33 @@ 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; prepend origin and append key fragment client-side - dropZone.innerHTML = await response.text(); + htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' }); htmx.process(dropZone); - const shareLink = document.getElementById('share-link'); - shareLink.value = window.location.origin + shareLink.value + '#' + base64urlKey; + htmx.find('#share-link').value = window.location.origin + '/download#' + base64urlKey; } catch (err) { - dropZone.innerHTML = ` + htmx.swap(dropZone, `
${err.message}
- `; + `, + { swapStyle: 'innerHTML' }); } } function setStatus(msg) { - const el = document.getElementById('upload-status'); + const el = htmx.find('#upload-status'); if (el) el.textContent = msg; } -// ── Reset (called from onclick in server-rendered fragments) ────────────────── - function resetUpload() { selectedFile = null; fileInput.value = ''; - dropZone.innerHTML = promptHtml; + htmx.swap(dropZone, promptHtml, { swapStyle: 'innerHTML' }); } -// ── 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'); + await navigator.clipboard.writeText(htmx.find('#share-link').value); + const btn = htmx.find('#copy-btn'); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); } diff --git a/Backend/src/main/resources/templates/download/page.html b/Backend/src/main/resources/templates/download/page.html index 2ab594a..b42f137 100644 --- a/Backend/src/main/resources/templates/download/page.html +++ b/Backend/src/main/resources/templates/download/page.html @@ -3,10 +3,12 @@ - GTransfer + GTransfer - + + + @@ -18,8 +20,7 @@
🔒
-
-
Preparing…
+
Preparing…
diff --git a/Backend/src/main/resources/templates/upload/result.html b/Backend/src/main/resources/templates/upload/result.html index f623ee4..77828b6 100644 --- a/Backend/src/main/resources/templates/upload/result.html +++ b/Backend/src/main/resources/templates/upload/result.html @@ -5,8 +5,7 @@
Your file is ready to share
- +