Implement wisp: async encrypted single-retrieval file drop
All checks were successful
Docker / build-and-push (push) Successful in 3m33s

Client-side libsodium encryption with the key in the URL fragment, an
Express/SQLite server holding ciphertext until a confirm-token round trip
proves successful decrypt (avoiding the delete-on-first-byte race), TTL
sweep for unclaimed drops, and a password-gated upload UI styled to match
flit. Dockerized to match the project family's conventions, with a named
volume so the DB/blobs survive redeploys, and a Gitea Actions workflow to
build and push the image.
This commit is contained in:
explewd
2026-07-09 19:14:54 +02:00
parent 8fd73155bd
commit 22619ebd11
30 changed files with 4412 additions and 0 deletions

14
client/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>wisp</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

18
client/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "wisp-client",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build"
},
"dependencies": {
"libsodium-wrappers": "^0.8.4"
},
"devDependencies": {
"@types/libsodium-wrappers": "^0.7.14",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#080808"/>
<text x="16" y="23" text-anchor="middle"
font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace"
font-size="19" font-weight="700" fill="#00e87a">&gt;_</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

44
client/src/api.ts Normal file
View File

@@ -0,0 +1,44 @@
export async function checkPassword(password: string): Promise<boolean> {
const res = await fetch('/api/auth/check', {
method: 'POST',
headers: { 'X-Upload-Password': password },
})
const body = (await res.json()) as { ok: boolean }
return body.ok
}
export async function uploadDrop(
ciphertext: Uint8Array,
password: string,
): Promise<{ id: string; ttl: number }> {
const res = await fetch('/api/drops', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Upload-Password': password,
},
body: ciphertext.slice().buffer,
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `upload failed: ${res.status}`)
}
return res.json()
}
export async function downloadDrop(
id: string,
): Promise<{ ciphertext: Uint8Array; confirmToken: string }> {
const res = await fetch(`/api/drops/${id}`)
if (!res.ok) throw new Error(`download failed: ${res.status}`)
const confirmToken = res.headers.get('X-Confirm-Token') ?? ''
const ciphertext = new Uint8Array(await res.arrayBuffer())
return { ciphertext, confirmToken }
}
export async function confirmDrop(id: string, confirmToken: string): Promise<void> {
await fetch(`/api/drops/${id}/confirm`, {
method: 'POST',
headers: { Authorization: `Bearer ${confirmToken}` },
})
}

65
client/src/crypto.ts Normal file
View File

@@ -0,0 +1,65 @@
import sodium from 'libsodium-wrappers'
export interface EncryptedPayload {
ciphertext: Uint8Array
keyB64: string
}
interface Envelope {
filename: string
contentType: string
}
function concat(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const p of parts) {
out.set(p, offset)
offset += p.length
}
return out
}
export async function encryptFile(file: File): Promise<EncryptedPayload> {
await sodium.ready
const key = sodium.crypto_secretbox_keygen()
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const envelope: Envelope = { filename: file.name, contentType: file.type }
const envelopeBytes = sodium.from_string(JSON.stringify(envelope))
const envelopeLen = new Uint8Array(4)
new DataView(envelopeLen.buffer).setUint32(0, envelopeBytes.length, false)
const fileBytes = new Uint8Array(await file.arrayBuffer())
const plaintext = concat(envelopeLen, envelopeBytes, fileBytes)
const sealed = sodium.crypto_secretbox_easy(plaintext, nonce, key)
const ciphertext = concat(nonce, sealed)
return { ciphertext, keyB64: sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING) }
}
export async function decryptBlob(
ciphertext: Uint8Array,
keyB64: string,
): Promise<{ file: Blob; filename: string }> {
await sodium.ready
const key = sodium.from_base64(keyB64, sodium.base64_variants.URLSAFE_NO_PADDING)
const nonce = ciphertext.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const sealed = ciphertext.slice(sodium.crypto_secretbox_NONCEBYTES)
const plaintext = sodium.crypto_secretbox_open_easy(sealed, nonce, key)
const envelopeLen = new DataView(plaintext.buffer, plaintext.byteOffset, 4).getUint32(0, false)
const envelopeBytes = plaintext.slice(4, 4 + envelopeLen)
const envelope: Envelope = JSON.parse(sodium.to_string(envelopeBytes))
const fileBytes = plaintext.slice(4 + envelopeLen)
return {
file: new Blob([fileBytes], { type: envelope.contentType || 'application/octet-stream' }),
filename: envelope.filename,
}
}

45
client/src/download.ts Normal file
View File

@@ -0,0 +1,45 @@
import { decryptBlob } from './crypto'
import { downloadDrop, confirmDrop } from './api'
import { renderHelpButton } from './help'
export function renderDownloadPage(root: HTMLElement, id: string): void {
root.innerHTML = ''
const header = document.createElement('div')
header.className = 'header-row'
header.innerHTML = `<h1>wisp</h1>`
renderHelpButton(header)
root.appendChild(header)
const card = document.createElement('div')
card.className = 'card'
card.innerHTML = `<div id="status" class="status-row"><span class="spinner"></span>fetching…</div>`
root.appendChild(card)
const status = card.querySelector<HTMLElement>('#status')!
run(id, status).catch((err) => {
status.outerHTML = `<div class="error-banner">${err instanceof Error ? err.message : 'Something went wrong.'}</div>`
})
}
async function run(id: string, status: HTMLElement): Promise<void> {
const key = new URLSearchParams(location.hash.slice(1)).get('k')
if (!key) throw new Error('Missing decryption key — check the full link was copied.')
const { ciphertext, confirmToken } = await downloadDrop(id)
status.innerHTML = `<span class="spinner"></span>decrypting…`
const { file, filename } = await decryptBlob(ciphertext, key)
if (confirmToken) await confirmDrop(id, confirmToken)
const url = URL.createObjectURL(file)
const a = document.createElement('a')
a.href = url
a.download = filename || 'download'
a.click()
URL.revokeObjectURL(url)
status.outerHTML = `<p class="hint">downloaded ${filename || 'file'} — this link is now burned</p>`
}

49
client/src/help.ts Normal file
View File

@@ -0,0 +1,49 @@
export function renderHelpButton(container: HTMLElement): void {
const btn = document.createElement('button')
btn.className = 'help-btn'
btn.textContent = '?'
btn.title = 'How wisp works'
btn.addEventListener('click', openHelpModal)
container.appendChild(btn)
}
function openHelpModal(): void {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal-card">
<div class="modal-header">
<span>how wisp works</span>
<button class="help-btn" id="modal-close">×</button>
</div>
<h3>flow</h3>
<ol>
<li>Your file is encrypted in the browser, before anything is sent.</li>
<li>Only the ciphertext is uploaded. The decryption key stays in the
link's fragment (after <code>#</code>), which browsers never send
to the server.</li>
<li>You share the link however you like.</li>
<li>The recipient opens it, downloads the ciphertext, and decrypts
it locally. Once that succeeds, the server deletes the file.</li>
</ol>
<h3>security</h3>
<ul>
<li>The server never sees the filename, contents, or key &mdash;
only opaque encrypted bytes.</li>
<li>Each link works once. After a successful download it's burned;
unopened links expire automatically after the TTL.</li>
<li>The link itself is the credential &mdash; anyone who has the
full link (including the <code>#k=...</code> part) can retrieve
the file once. Share it over a channel you trust.</li>
<li>This does not protect against a recipient who saves or
re-shares the file after decrypting it &mdash; that's out of
scope by design, same as it is for flit.</li>
</ul>
</div>
`
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove()
})
overlay.querySelector('#modal-close')!.addEventListener('click', () => overlay.remove())
document.body.appendChild(overlay)
}

11
client/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { renderUploadPage } from './upload'
import { renderDownloadPage } from './download'
const root = document.querySelector<HTMLElement>('#app')!
const match = location.pathname.match(/^\/d\/([^/]+)/)
if (match) {
renderDownloadPage(root, match[1])
} else {
renderUploadPage(root)
}

265
client/src/style.css Normal file
View File

@@ -0,0 +1,265 @@
:root {
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app {
width: 420px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
}
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
/* ── Header ─────────────────────────────────────────────────────────── */
.header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.header-row h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
text-align: center;
margin: 0;
}
.header-row h1::before {
content: '> ';
color: var(--muted);
font-weight: 400;
}
.help-btn {
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
width: 22px;
height: 22px;
line-height: 20px;
border-radius: 50%;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
}
.help-btn:hover {
border-color: var(--accent-border);
color: var(--accent);
}
/* ── Card / stack ───────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
background: var(--bg-alt);
box-shadow: var(--shadow);
}
.stack { display: flex; flex-direction: column; gap: 12px; }
.hint {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
text-align: center;
margin-bottom: 4px;
}
/* ── Buttons ────────────────────────────────────────────────────────── */
.btn {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.01em;
}
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-weight: 700;
}
.btn-primary:hover { background: #00ff88; border-color: #00ff88; color: #000; }
/* ── Inputs ─────────────────────────────────────────────────────────── */
.text-input {
font-family: var(--mono);
font-size: 13px;
width: 100%;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--heading);
}
.text-input::placeholder { color: var(--muted); }
.text-input:focus { outline: none; border-color: var(--accent-border); }
/* ── Dropzone ───────────────────────────────────────────────────────── */
.dropzone {
position: relative;
border: 1px dashed var(--border);
border-radius: 8px;
padding: 24px 16px;
text-align: center;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone::before { content: '+ '; color: var(--accent); }
.dropzone input[type='file'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.dropzone:hover { border-color: var(--accent-border); background: var(--accent-dim); color: var(--heading); }
/* ── Status / error ─────────────────────────────────────────────────── */
.status-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(0,232,122,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-banner {
font-family: var(--mono);
font-size: 13px;
color: #ff6b6b;
border: 1px solid rgba(255,107,107,0.25);
border-radius: 6px;
padding: 10px 14px;
background: rgba(255,107,107,0.06);
}
/* ── Result link ────────────────────────────────────────────────────── */
.link-box {
word-break: break-all;
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
}
/* ── Help modal ─────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
backdrop-filter: blur(4px);
}
.modal-card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: 100%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
font-family: var(--mono);
font-size: 13px;
color: var(--heading);
}
.modal-card ol, .modal-card ul {
padding-left: 1.2em;
margin: 0 0 1em;
}
.modal-card li { margin-bottom: 0.5em; font-size: 14px; }
.modal-card h3 { font-size: 13px; margin: 0 0 0.5em; color: var(--accent); }

109
client/src/upload.ts Normal file
View File

@@ -0,0 +1,109 @@
import { encryptFile } from './crypto'
import { checkPassword, uploadDrop } from './api'
import { renderHelpButton } from './help'
function renderHeader(root: HTMLElement): void {
const header = document.createElement('div')
header.className = 'header-row'
header.innerHTML = `<h1>wisp</h1>`
renderHelpButton(header)
root.appendChild(header)
}
export function renderUploadPage(root: HTMLElement): void {
root.innerHTML = ''
renderHeader(root)
const card = document.createElement('div')
card.className = 'card'
card.innerHTML = `
<form id="password-form" class="stack">
<input id="password" class="text-input" type="password" placeholder="password" autocomplete="off" />
<button type="submit" class="btn btn-primary">unlock</button>
</form>
<div id="password-error"></div>
`
root.appendChild(card)
const passwordForm = card.querySelector<HTMLFormElement>('#password-form')!
passwordForm.addEventListener('submit', async (e) => {
e.preventDefault()
const password = card.querySelector<HTMLInputElement>('#password')!.value
const errorEl = card.querySelector<HTMLElement>('#password-error')!
errorEl.innerHTML = ''
const ok = await checkPassword(password)
if (!ok) {
errorEl.innerHTML = `<div class="error-banner">Wrong password.</div>`
return
}
renderUploadForm(root, password)
})
}
function renderUploadForm(root: HTMLElement, password: string): void {
root.innerHTML = ''
renderHeader(root)
const card = document.createElement('div')
card.className = 'card'
card.innerHTML = `
<form id="upload-form" class="stack">
<label class="dropzone">
<span id="file-label">drop a file, or click to choose</span>
<input id="file" type="file" required />
</label>
<button type="submit" class="btn btn-primary">encrypt &amp; upload</button>
</form>
<div id="status"></div>
<div id="result"></div>
`
root.appendChild(card)
const fileInput = card.querySelector<HTMLInputElement>('#file')!
const fileLabel = card.querySelector<HTMLElement>('#file-label')!
fileInput.addEventListener('change', () => {
fileLabel.textContent = fileInput.files?.[0]?.name ?? 'drop a file, or click to choose'
})
const form = card.querySelector<HTMLFormElement>('#upload-form')!
form.addEventListener('submit', async (e) => {
e.preventDefault()
const status = card.querySelector<HTMLElement>('#status')!
const result = card.querySelector<HTMLElement>('#result')!
const file = fileInput.files?.[0]
if (!file) return
status.innerHTML = `<div class="status-row"><span class="spinner"></span>encrypting…</div>`
result.innerHTML = ''
try {
const { ciphertext, keyB64 } = await encryptFile(file)
status.innerHTML = `<div class="status-row"><span class="spinner"></span>uploading…</div>`
const { id } = await uploadDrop(ciphertext, password)
const link = `${location.origin}/d/${id}#k=${keyB64}`
status.innerHTML = ''
result.innerHTML = `
<div class="stack">
<p class="hint">share this link — it works once</p>
<div class="link-box">${link}</div>
<button type="button" id="copy-link" class="btn">copy to clipboard</button>
</div>
`
const copyBtn = result.querySelector<HTMLButtonElement>('#copy-link')!
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(link)
copyBtn.textContent = 'copied!'
} catch {
copyBtn.textContent = 'copy failed — select manually'
}
setTimeout(() => (copyBtn.textContent = 'copy to clipboard'), 1500)
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed.'
status.innerHTML = `<div class="error-banner">${message}</div>`
}
})
}

12
client/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

9
client/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})