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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
client/node_modules
client/dist
server/node_modules
server/dist
data
docker-compose.yml
Dockerfile
node_modules
.env
.git
README.md
IMPLEMENTATION.md

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Image is built and pushed by .gitea/workflows/docker.yml
IMAGE=your-registry.example.com/your-org/wisp:latest
# Public URL is fronted by a reverse proxy — this container just needs to
# listen on HOST_PORT.
HOST_PORT=3040
TTL_SECONDS=259200
MAX_UPLOAD_BYTES=209715200
UPLOAD_PASSWORD=change-me

View File

@@ -0,0 +1,53 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
# Create a Gitea PAT with packages:write scope and add it as a
# repository secret named TKNTKN (Settings → Secrets)
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.env
dist
data
*.tsbuildinfo

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# ── Stage 1: build the Vite client ───────────────────────────────────────────
FROM node:24-slim AS client-build
WORKDIR /app
COPY client/package*.json client/
RUN npm install --prefix client
COPY client/ client/
RUN npm run build --prefix client
# ── Stage 2: compile the TypeScript server ────────────────────────────────────
FROM node:24-slim AS server-build
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source.
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server/package*.json server/
COPY server/tsconfig.json server/
RUN npm install --prefix server
COPY server/src/ server/src/
RUN npm run build --prefix server
# ── Stage 3: runtime ──────────────────────────────────────────────────────────
FROM node:24-slim
WORKDIR /app
# better-sqlite3 needs to rebuild its native addon here too, then the
# build toolchain is dropped again to keep the runtime image lean.
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Production-only server dependencies (no tsx, no typescript)
COPY server/package*.json server/
RUN npm install --prefix server --omit=dev \
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
# Compiled server + built client
COPY --from=server-build /app/server/dist server/dist
COPY --from=client-build /app/client/dist client/dist
# Do NOT copy data/ — that's a mutable runtime volume holding the SQLite
# DB and ciphertext blobs. Baking it into the image would clobber it on
# every redeploy.
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/app/data
EXPOSE 3000
CMD ["node", "server/dist/index.js"]

View File

@@ -134,6 +134,8 @@ disk space per upload:
(Ed25519 pubkey signs the upload request) to unlock a larger size limit (Ed25519 pubkey signs the upload request) to unlock a larger size limit
or bypass rate limits — reuses identity infra that already exists rather or bypass rate limits — reuses identity infra that already exists rather
than building new auth. Optional, not required for v1. than building new auth. Optional, not required for v1.
- For first version - require a password before showing upload screen. Configurable in .env
## Open questions ## Open questions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 explewd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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',
},
},
})

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
image: ${IMAGE}
container_name: wisp
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3040}:3000"
environment:
- TTL_SECONDS=${TTL_SECONDS:-259200}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-209715200}
- UPLOAD_PASSWORD=${UPLOAD_PASSWORD}
volumes:
# Named volume, not a bind mount into the build context — the SQLite
# DB and ciphertext blobs live here and must survive image redeploys.
- wisp-data:/app/data
volumes:
wisp-data:

3290
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "wisp",
"private": true,
"license": "MIT",
"workspaces": [
"client",
"server"
],
"scripts": {
"dev": "concurrently -n client,server -c cyan,yellow \"npm run dev --workspace=client\" \"npm run dev --workspace=server\""
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

22
server/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "wisp-server",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "tsx watch --env-file=../.env src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node --env-file=../.env dist/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/node": "^22.7.4",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}

24
server/src/cleanup.ts Normal file
View File

@@ -0,0 +1,24 @@
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
// Sweeps expired-but-never-confirmed drops. The TTL is the backstop for
// links nobody ever opens; confirmed downloads are deleted immediately by
// the confirm endpoint instead.
export function sweepExpiredDrops(): void {
const now = Math.floor(Date.now() / 1000)
const expired = db
.prepare(`SELECT id FROM drops WHERE expires_at < ? AND burned = 0`)
.all(now) as { id: string }[]
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
db.prepare(`UPDATE drops SET burned = 1 WHERE id = ?`).run(id)
}
db.prepare(`DELETE FROM confirm_tokens WHERE expires_at < ?`).run(now)
}
export function startCleanupJob(): NodeJS.Timeout {
return setInterval(sweepExpiredDrops, config.cleanupIntervalSeconds * 1000)
}

15
server/src/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from 'node:path'
if (!process.env.UPLOAD_PASSWORD) {
console.error('UPLOAD_PASSWORD is not set — refusing to start with an open upload gate.')
process.exit(1)
}
export const config = {
port: Number(process.env.PORT ?? 3000),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
ttlSeconds: Number(process.env.TTL_SECONDS ?? 60 * 60 * 24 * 3), // 3 days
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 200 * 1024 * 1024), // 200MB
uploadPassword: process.env.UPLOAD_PASSWORD,
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
}

35
server/src/db.ts Normal file
View File

@@ -0,0 +1,35 @@
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
import { config } from './config.js'
fs.mkdirSync(config.dataDir, { recursive: true })
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
export const db = new Database(path.join(config.dataDir, 'wisp.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS drops (
id TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
downloaded_at INTEGER,
burned INTEGER NOT NULL DEFAULT 0
);
-- Single-use tokens issued at download time, redeemed by the confirm
-- endpoint. Kept in SQLite (not memory) so a server restart between
-- download and confirm doesn't strand the client with an unredeemable
-- token.
CREATE TABLE IF NOT EXISTS confirm_tokens (
token TEXT PRIMARY KEY,
drop_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
`)
export function blobPath(id: string): string {
return path.join(config.dataDir, 'blobs', id)
}

27
server/src/ids.ts Normal file
View File

@@ -0,0 +1,27 @@
import { randomBytes } from 'node:crypto'
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
// 128-bit random value, rendered as base62 — matches the blob-id shape
// described in IMPLEMENTATION.md (unguessable, distinct from the key).
function randomBase62(bits: number): string {
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
let value = 0n
for (const b of bytes) value = (value << 8n) | BigInt(b)
let out = ''
const base = BigInt(BASE62.length)
while (value > 0n) {
out = BASE62[Number(value % base)] + out
value /= base
}
return out.padStart(Math.ceil((bits / Math.log2(62))), '0')
}
export function newBlobId(): string {
return randomBase62(128)
}
export function newConfirmToken(): string {
return randomBase62(128)
}

21
server/src/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from './config.js'
import { router } from './routes.js'
import { startCleanupJob, sweepExpiredDrops } from './cleanup.js'
const app = express()
app.use('/api', router)
const clientDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../client/dist')
app.use(express.static(clientDist))
app.get('*', (_req, res) => res.sendFile(path.join(clientDist, 'index.html')))
sweepExpiredDrops()
startCleanupJob()
app.listen(config.port, () => {
console.log(`wisp server listening on :${config.port}`)
})

123
server/src/routes.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Router } from 'express'
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { newBlobId, newConfirmToken } from './ids.js'
export const router = Router()
function checkUploadPassword(req: import('express').Request): boolean {
return req.get('X-Upload-Password') === config.uploadPassword
}
// Lets the client gate the upload UI behind a password without uploading
// anything yet.
router.post('/auth/check', (req, res) => {
res.json({ ok: checkUploadPassword(req) })
})
router.post('/drops', (req, res) => {
if (!checkUploadPassword(req)) {
res.status(401).json({ error: 'bad password' })
return
}
const contentLength = Number(req.get('Content-Length') ?? 0)
if (contentLength > config.maxUploadBytes) {
res.status(413).json({ error: 'file too large' })
return
}
const id = newBlobId()
const dest = blobPath(id)
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
let bytesReceived = 0
let aborted = false
req.on('data', (chunk: Buffer) => {
bytesReceived += chunk.length
if (bytesReceived > config.maxUploadBytes) {
aborted = true
writeStream.destroy()
req.destroy()
}
})
req.pipe(writeStream)
writeStream.on('error', () => {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
})
writeStream.on('finish', () => {
if (aborted) {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
return
}
const now = Math.floor(Date.now() / 1000)
const expiresAt = now + config.ttlSeconds
db.prepare(
`INSERT INTO drops (id, size_bytes, created_at, expires_at) VALUES (?, ?, ?, ?)`,
).run(id, bytesReceived, now, expiresAt)
res.json({ id, ttl: config.ttlSeconds })
})
})
router.get('/drops/:id', (req, res) => {
const drop = db
.prepare(`SELECT * FROM drops WHERE id = ?`)
.get(req.params.id) as
| { id: string; burned: number; expires_at: number }
| undefined
if (!drop || drop.burned || drop.expires_at < Math.floor(Date.now() / 1000)) {
res.status(404).json({ error: 'not found' })
return
}
const path = blobPath(drop.id)
if (!fs.existsSync(path)) {
res.status(404).json({ error: 'not found' })
return
}
const token = newConfirmToken()
const tokenExpiresAt = Math.floor(Date.now() / 1000) + 60 * 15
db.prepare(
`INSERT INTO confirm_tokens (token, drop_id, expires_at) VALUES (?, ?, ?)`,
).run(token, drop.id, tokenExpiresAt)
res.setHeader('X-Confirm-Token', token)
res.setHeader('Content-Type', 'application/octet-stream')
fs.createReadStream(path).pipe(res)
})
router.post('/drops/:id/confirm', (req, res) => {
const auth = req.get('Authorization') ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : ''
const row = db
.prepare(`SELECT * FROM confirm_tokens WHERE token = ?`)
.get(token) as { token: string; drop_id: string; expires_at: number } | undefined
if (!row || row.drop_id !== req.params.id || row.expires_at < Math.floor(Date.now() / 1000)) {
res.status(400).json({ error: 'invalid or expired confirm token' })
return
}
// Single-use: delete the token before doing anything else.
db.prepare(`DELETE FROM confirm_tokens WHERE token = ?`).run(token)
fs.rm(blobPath(row.drop_id), { force: true }, () => {})
db.prepare(`UPDATE drops SET burned = 1, downloaded_at = ? WHERE id = ?`).run(
Math.floor(Date.now() / 1000),
row.drop_id,
)
res.json({ ok: true })
})

14
server/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}