working download
This commit is contained in:
87
Backend/src/main/resources/static/download.js
Normal file
87
Backend/src/main/resources/static/download.js
Normal file
@@ -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 = `
|
||||
<div class="drop-zone-icon mb-3">✅</div>
|
||||
<div class="fw-medium mb-1">${filename}</div>
|
||||
<div class="drop-zone-text mb-3">Your download has started.</div>
|
||||
<a href="/" class="btn btn-link drop-zone-text text-decoration-none">Send a file</a>`;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('download-state').innerHTML = `
|
||||
<div class="drop-zone-icon mb-3">⚠</div>
|
||||
<div class="drop-zone-text mb-3">${msg}</div>
|
||||
<a href="/" class="btn btn-link drop-zone-text text-decoration-none">Go home</a>`;
|
||||
}
|
||||
@@ -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 = `
|
||||
|
||||
32
Backend/src/main/resources/templates/download/page.html
Normal file
32
Backend/src/main/resources/templates/download/page.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="${filename != null ? filename + ' — GTransfer' : 'GTransfer'}">GTransfer</title>
|
||||
<link rel="stylesheet" th:href="@{/webjars/bootstrap/dist/css/bootstrap.min.css}">
|
||||
<link rel="stylesheet" th:href="@{/style.css}">
|
||||
<script th:src="@{/download.js}" defer></script>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
<nav class="navbar px-4 pt-3">
|
||||
<a class="brand fw-bold text-decoration-none fs-4" href="/">G<span>Transfer</span></a>
|
||||
<span class="badge-e2e rounded-pill fw-medium px-3 py-1">🔒 End-to-end encrypted</span>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1 d-flex align-items-center justify-content-center py-5 px-3">
|
||||
<div id="download-state" class="drop-zone text-center py-5 px-4" style="max-width: 520px; width: 100%;">
|
||||
<div class="drop-zone-icon mb-3">🔒</div>
|
||||
<div class="fw-medium mb-2" th:if="${filename != null}" th:text="${filename}"></div>
|
||||
<div class="drop-zone-text" id="download-status">Preparing…</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center p-4 small">
|
||||
<a href="https://github.com/gregor-lohaus/gtransfer">Open source</a>
|
||||
· No tracking · No ads
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user