From 266f9708e32eb0a1cf833c174c53427269410c98 Mon Sep 17 00:00:00 2001 From: explewd Date: Sun, 12 Jul 2026 19:29:46 +0200 Subject: [PATCH] Implement scoped upload invites Adds a second, narrower credential type alongside UPLOAD_PASSWORD: an admin (ADMIN_PASSWORD-gated, /admin) can mint time-boxed, use-limited, independently revocable invite links (?invite=) that skip the password screen for one-off sharing without handing out the master password. Invite consumption is an atomic check-and-increment to avoid a race on single-use invites; admin surface 503s (not boot failure) when ADMIN_PASSWORD is unset. Co-Authored-By: Claude Sonnet 5 --- .env.example | 4 + README.md | 18 ++++- client/src/admin.ts | 173 +++++++++++++++++++++++++++++++++++++++++++ client/src/api.ts | 8 +- client/src/main.ts | 3 + client/src/upload.ts | 16 ++++ server/src/admin.ts | 72 ++++++++++++++++++ server/src/auth.ts | 36 +++++++++ server/src/config.ts | 3 + server/src/db.ts | 21 ++++++ server/src/ids.ts | 4 + server/src/index.ts | 5 ++ server/src/routes.ts | 20 +++-- 13 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 client/src/admin.ts create mode 100644 server/src/admin.ts diff --git a/.env.example b/.env.example index 4d0bbcd..9e7e10f 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ HOST_PORT=3040 TTL_SECONDS=259200 MAX_UPLOAD_BYTES=209715200 UPLOAD_PASSWORD=change-me + +# Optional. Unset disables the /admin invite-management page entirely +# (503, not a boot failure). Set to enable minting scoped upload invites. +ADMIN_PASSWORD=change-me-too diff --git a/README.md b/README.md index ac5629c..c286683 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,22 @@ territory for this project family, not a variant of something already built. Full design, including why "delete on first byte served" is the wrong default, is in [IMPLEMENTATION.md](./IMPLEMENTATION.md). +## Upload access + +Uploading is gated by `UPLOAD_PASSWORD` (required — the server refuses to +start without it), shared by everyone who's allowed to upload. + +For granting access to one person for one reason without handing them the +master password, admins can mint **scoped upload invites**: a link +(`?invite=`) that skips the password screen, valid for a limited +number of uses and a limited time, independently revocable. Invite +management lives at `/admin`, gated by a separate `ADMIN_PASSWORD` — unset, +the admin surface is disabled entirely (503s) and everything else works as +usual. + ## Status -Design only. No code yet. +Implemented: upload/download, encryption, TTL cleanup, password gate, and +scoped upload invites. See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the +deletion-race design and [PROPOSAL-invite-links.md](./PROPOSAL-invite-links.md) +for the invite feature's design rationale. diff --git a/client/src/admin.ts b/client/src/admin.ts new file mode 100644 index 0000000..280b337 --- /dev/null +++ b/client/src/admin.ts @@ -0,0 +1,173 @@ +import { renderHelpButton } from './help' + +type Invite = { + token: string + label: string | null + max_uses: number + used_count: number + created_at: number + expires_at: number +} + +async function adminApi(password: string, path: string, opts: RequestInit = {}): Promise { + const res = await fetch(`/api/admin${path}`, { + ...opts, + headers: { 'X-Admin-Password': password, ...(opts.headers as Record) }, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(body.error ?? `request failed: ${res.status}`) + } + return res.status === 204 ? (undefined as T) : res.json() +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function fmtTime(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleString() +} + +function renderHeader(root: HTMLElement): void { + const header = document.createElement('div') + header.className = 'header-row' + header.innerHTML = `

wisp admin

` + renderHelpButton(header) + root.appendChild(header) +} + +export function renderAdminPage(root: HTMLElement): void { + root.innerHTML = '' + renderHeader(root) + + const card = document.createElement('div') + card.className = 'card' + card.innerHTML = ` +
+ + +
+
+ ` + root.appendChild(card) + + const form = card.querySelector('#admin-password-form')! + form.addEventListener('submit', async (e) => { + e.preventDefault() + const password = card.querySelector('#admin-password')!.value + const errorEl = card.querySelector('#admin-password-error')! + errorEl.innerHTML = '' + + try { + const body = await adminApi<{ ok: boolean }>(password, '/auth/check', { method: 'POST' }) + if (!body.ok) { + errorEl.innerHTML = `
Wrong password.
` + return + } + renderInvitePanel(root, password) + } catch (err) { + const message = err instanceof Error ? err.message : 'unlock failed' + errorEl.innerHTML = `
${message}
` + } + }) +} + +function renderInvitePanel(root: HTMLElement, password: string): void { + root.innerHTML = '' + renderHeader(root) + + const card = document.createElement('div') + card.className = 'card' + card.innerHTML = ` +
+ + + + +
+
+
+
+ invites + +
+

loading…

+ ` + root.appendChild(card) + + async function refresh(): Promise { + const listEl = card.querySelector('#invite-list')! + const refreshBtn = card.querySelector('#refresh-invites')! + refreshBtn.disabled = true + let invites: Invite[] + try { + invites = await adminApi(password, '/invites') + } finally { + refreshBtn.disabled = false + } + if (invites.length === 0) { + listEl.innerHTML = `

no invites yet

` + return + } + + const now = Date.now() / 1000 + listEl.innerHTML = invites + .map((inv) => { + const status = inv.expires_at < now ? 'expired' : inv.used_count >= inv.max_uses ? 'exhausted' : 'active' + return ` + + ` + }) + .join('') + + listEl.querySelectorAll('.revoke-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + await adminApi(password, `/invites/${btn.dataset.token}`, { method: 'DELETE' }) + refresh() + }) + }) + } + + card.querySelector('#refresh-invites')!.addEventListener('click', () => refresh()) + + const inviteForm = card.querySelector('#invite-form')! + inviteForm.addEventListener('submit', async (e) => { + e.preventDefault() + const errorEl = card.querySelector('#invite-create-error')! + const createdEl = card.querySelector('#invite-created')! + errorEl.innerHTML = '' + createdEl.innerHTML = '' + + const label = card.querySelector('#invite-label')!.value.trim() || undefined + const maxUsesRaw = card.querySelector('#invite-max-uses')!.value + const ttlHoursRaw = card.querySelector('#invite-ttl-hours')!.value + const maxUses = maxUsesRaw ? Number(maxUsesRaw) : undefined + const ttlSeconds = ttlHoursRaw ? Number(ttlHoursRaw) * 3600 : undefined + + try { + const { url } = await adminApi<{ token: string; url: string }>(password, '/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label, maxUses, ttlSeconds }), + }) + createdEl.innerHTML = ` +
+

share this link

+ +
+ ` + inviteForm.reset() + refresh() + } catch (err) { + const message = err instanceof Error ? err.message : 'failed to create invite' + errorEl.innerHTML = `
${message}
` + } + }) + + refresh() +} diff --git a/client/src/api.ts b/client/src/api.ts index 62809ee..bf9b145 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,9 +1,9 @@ -export type Credential = { kind: 'password' | 'hash'; value: string } +export type Credential = { kind: 'password' | 'hash' | 'invite'; value: string } function credentialHeaders(cred: Credential): Record { - return cred.kind === 'password' - ? { 'X-Upload-Password': cred.value } - : { 'X-Upload-Password-Hash': cred.value } + if (cred.kind === 'password') return { 'X-Upload-Password': cred.value } + if (cred.kind === 'hash') return { 'X-Upload-Password-Hash': cred.value } + return { 'X-Upload-Invite': cred.value } } export async function checkCredential(cred: Credential): Promise { diff --git a/client/src/main.ts b/client/src/main.ts index 30849bf..0b190d5 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,11 +1,14 @@ import { renderUploadPage } from './upload' import { renderDownloadPage } from './download' +import { renderAdminPage } from './admin' const root = document.querySelector('#app')! const match = location.pathname.match(/^\/d\/([^/]+)/) if (match) { renderDownloadPage(root, match[1]) +} else if (location.pathname === '/admin') { + renderAdminPage(root) } else { renderUploadPage(root) } diff --git a/client/src/upload.ts b/client/src/upload.ts index 6f06ef4..83f718f 100644 --- a/client/src/upload.ts +++ b/client/src/upload.ts @@ -26,7 +26,23 @@ async function tryFragmentUnlock(root: HTMLElement): Promise { return false } +// A scoped, revocable upload invite, passed as ?invite=... . Unlike the +// #psst= bypass hash, this must reach the server to be validated, so it +// lives in the query string, not the fragment. +async function tryInviteUnlock(root: HTMLElement): Promise { + const invite = new URLSearchParams(location.search).get('invite') + if (!invite) return false + + const cred: Credential = { kind: 'invite', value: invite } + if (await checkCredential(cred)) { + renderUploadForm(root, cred) + return true + } + return false +} + export async function renderUploadPage(root: HTMLElement): Promise { + if (await tryInviteUnlock(root)) return if (await tryFragmentUnlock(root)) return root.innerHTML = '' diff --git a/server/src/admin.ts b/server/src/admin.ts new file mode 100644 index 0000000..1cb1298 --- /dev/null +++ b/server/src/admin.ts @@ -0,0 +1,72 @@ +import { Router } from 'express' +import { config } from './config.js' +import { db } from './db.js' +import { newInviteToken } from './ids.js' +import { checkAdminCredential } from './auth.js' + +export const router = Router() + +// Stateless, same shape as the upload gate — no session/cookie. Disabled +// outright (503) rather than always-401 when ADMIN_PASSWORD is unset, so a +// deployment that doesn't want invite management doesn't expose an admin +// surface to probe at all. +router.use((req, res, next) => { + if (!config.adminPassword) { + res.status(503).json({ error: 'admin disabled — ADMIN_PASSWORD not set' }) + return + } + if (req.path === '/auth/check') { + next() + return + } + if (!checkAdminCredential(req)) { + res.status(401).json({ error: 'bad admin password' }) + return + } + next() +}) + +router.post('/auth/check', (req, res) => { + res.json({ ok: checkAdminCredential(req) }) +}) + +router.get('/invites', (_req, res) => { + const rows = db + .prepare( + `SELECT token, label, max_uses, used_count, created_at, expires_at + FROM upload_invites ORDER BY created_at DESC`, + ) + .all() + res.json(rows) +}) + +router.post('/invites', (req, res) => { + const body = req.body as { label?: string; maxUses?: number; ttlSeconds?: number } + + // Single-use is the safer default — accidental over-sharing requires an + // explicit larger number. + const maxUses = body.maxUses && body.maxUses > 0 ? Math.floor(body.maxUses) : 1 + const ttlSeconds = + body.ttlSeconds && body.ttlSeconds > 0 ? Math.floor(body.ttlSeconds) : 60 * 60 * 24 + + const token = newInviteToken() + const now = Math.floor(Date.now() / 1000) + const expiresAt = now + ttlSeconds + + db.prepare( + `INSERT INTO upload_invites (token, label, max_uses, used_count, created_at, expires_at) + VALUES (?, ?, ?, 0, ?, ?)`, + ).run(token, body.label?.trim() || null, maxUses, now, expiresAt) + + const url = `${req.protocol}://${req.get('host')}/?invite=${token}` + res.json({ token, url }) +}) + +router.delete('/invites/:token', (req, res) => { + // Revoke immediately without deleting history: pin used_count to max_uses + // so the invite reads as exhausted rather than disappearing from the list. + db.prepare(`UPDATE upload_invites SET max_uses = used_count WHERE token = ?`).run( + req.params.token, + ) + res.json({ ok: true }) +}) diff --git a/server/src/auth.ts b/server/src/auth.ts index 7c994cf..592c4fc 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -1,6 +1,7 @@ import { createHash, timingSafeEqual } from 'node:crypto' import type { Request } from 'express' import { config } from './config.js' +import { db } from './db.js' const expectedHash = createHash('sha256').update(config.uploadPassword).digest('hex') @@ -22,3 +23,38 @@ export function checkUploadCredential(req: Request): boolean { return false } + +export function checkAdminCredential(req: Request): boolean { + if (!config.adminPassword) return false + const plain = req.get('X-Admin-Password') + if (plain === undefined) return false + return timingSafeStringEqual(plain, config.adminPassword) +} + +// Read-only: does this invite exist, is it unexpired, does it have uses +// left. Does NOT increment used_count — used to gate the upload UI (e.g. +// loading the page with ?invite=... ) without burning a use before the +// holder has actually chosen a file. +export function checkInviteValid(token: string): boolean { + if (!token) return false + const now = Math.floor(Date.now() / 1000) + const row = db + .prepare(`SELECT used_count, max_uses, expires_at FROM upload_invites WHERE token = ?`) + .get(token) as { used_count: number; max_uses: number; expires_at: number } | undefined + return !!row && row.used_count < row.max_uses && row.expires_at > now +} + +// Atomic check-and-increment, single UPDATE ... WHERE — avoids a race +// between two near-simultaneous uploads both passing a stale check against +// the same single-use invite. +export function tryConsumeInvite(token: string): boolean { + if (!token) return false + const now = Math.floor(Date.now() / 1000) + const result = db + .prepare( + `UPDATE upload_invites SET used_count = used_count + 1 + WHERE token = ? AND used_count < max_uses AND expires_at > ?`, + ) + .run(token, now) + return result.changes > 0 +} diff --git a/server/src/config.ts b/server/src/config.ts index d8cac72..b2e99f1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,4 +12,7 @@ export const config = { 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), + // Unset (unlike UPLOAD_PASSWORD) does not fail startup — it just means the + // admin surface (invite management) is disabled for this deployment. + adminPassword: process.env.ADMIN_PASSWORD || undefined, } diff --git a/server/src/db.ts b/server/src/db.ts index a857d76..8e81aef 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -28,8 +28,29 @@ db.exec(` drop_id TEXT NOT NULL, expires_at INTEGER NOT NULL ); + + -- Scoped, revocable, time-boxed alternative to UPLOAD_PASSWORD. Valid iff + -- used_count < max_uses AND expires_at > now(); consuming one must be an + -- atomic UPDATE ... WHERE, not SELECT-then-UPDATE, to avoid a race between + -- two near-simultaneous uploads against a single-use invite. + CREATE TABLE IF NOT EXISTS upload_invites ( + token TEXT PRIMARY KEY, + label TEXT, + max_uses INTEGER NOT NULL, + used_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); `) +// Column added after the initial release — ALTER TABLE ... ADD COLUMN on an +// already-migrated db throws, which we ignore. +try { + db.exec(`ALTER TABLE drops ADD COLUMN used_by_invite TEXT`) +} catch { + // already migrated +} + export function blobPath(id: string): string { return path.join(config.dataDir, 'blobs', id) } diff --git a/server/src/ids.ts b/server/src/ids.ts index 35020f6..e7a13c8 100644 --- a/server/src/ids.ts +++ b/server/src/ids.ts @@ -25,3 +25,7 @@ export function newBlobId(): string { export function newConfirmToken(): string { return randomBase62(128) } + +export function newInviteToken(): string { + return randomBase62(128) +} diff --git a/server/src/index.ts b/server/src/index.ts index 04c7617..a6003d0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,11 +3,16 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { config } from './config.js' import { router } from './routes.js' +import { router as adminRouter } from './admin.js' import { startCleanupJob, sweepExpiredDrops } from './cleanup.js' const app = express() app.use('/api', router) +// express.json() is scoped to /api/admin only — /api/drops relies on the +// raw request stream for the upload body and must not go through a body +// parser. +app.use('/api/admin', express.json(), adminRouter) const clientDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../client/dist') app.use(express.static(clientDist)) diff --git a/server/src/routes.ts b/server/src/routes.ts index c75546e..5c59264 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -3,18 +3,26 @@ import fs from 'node:fs' import { config } from './config.js' import { db, blobPath } from './db.js' import { newBlobId, newConfirmToken } from './ids.js' -import { checkUploadCredential } from './auth.js' +import { checkUploadCredential, checkInviteValid, tryConsumeInvite } from './auth.js' export const router = Router() -// Lets the client gate the upload UI behind a password without uploading -// anything yet. +// Lets the client gate the upload UI behind a password (or invite) without +// uploading anything yet. Invite check here is read-only — it does not +// consume a use, that only happens on the actual POST /drops. router.post('/auth/check', (req, res) => { + const inviteToken = req.get('X-Upload-Invite') + if (inviteToken) { + res.json({ ok: checkInviteValid(inviteToken) }) + return + } res.json({ ok: checkUploadCredential(req) }) }) router.post('/drops', (req, res) => { - if (!checkUploadCredential(req)) { + const inviteToken = req.get('X-Upload-Invite') + const authorized = inviteToken ? tryConsumeInvite(inviteToken) : checkUploadCredential(req) + if (!authorized) { res.status(401).json({ error: 'bad password' }) return } @@ -58,8 +66,8 @@ router.post('/drops', (req, res) => { 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) + `INSERT INTO drops (id, size_bytes, created_at, expires_at, used_by_invite) VALUES (?, ?, ?, ?, ?)`, + ).run(id, bytesReceived, now, expiresAt, inviteToken ?? null) res.json({ id, ttl: config.ttlSeconds }) })