Implement scoped upload invites
All checks were successful
Docker / build-and-push (push) Successful in 3m9s

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=<token>) 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 <noreply@anthropic.com>
This commit is contained in:
explewd
2026-07-12 19:29:46 +02:00
parent 0b62776282
commit 266f9708e3
13 changed files with 372 additions and 11 deletions

View File

@@ -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

View File

@@ -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=<token>`) 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.

173
client/src/admin.ts Normal file
View File

@@ -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<T>(password: string, path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api/admin${path}`, {
...opts,
headers: { 'X-Admin-Password': password, ...(opts.headers as Record<string, string>) },
})
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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 = `<h1>wisp admin</h1>`
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 = `
<form id="admin-password-form" class="stack">
<input id="admin-password" class="text-input" type="password" placeholder="admin password" autocomplete="off" />
<button type="submit" class="btn btn-primary">unlock</button>
</form>
<div id="admin-password-error"></div>
`
root.appendChild(card)
const form = card.querySelector<HTMLFormElement>('#admin-password-form')!
form.addEventListener('submit', async (e) => {
e.preventDefault()
const password = card.querySelector<HTMLInputElement>('#admin-password')!.value
const errorEl = card.querySelector<HTMLElement>('#admin-password-error')!
errorEl.innerHTML = ''
try {
const body = await adminApi<{ ok: boolean }>(password, '/auth/check', { method: 'POST' })
if (!body.ok) {
errorEl.innerHTML = `<div class="error-banner">Wrong password.</div>`
return
}
renderInvitePanel(root, password)
} catch (err) {
const message = err instanceof Error ? err.message : 'unlock failed'
errorEl.innerHTML = `<div class="error-banner">${message}</div>`
}
})
}
function renderInvitePanel(root: HTMLElement, password: string): void {
root.innerHTML = ''
renderHeader(root)
const card = document.createElement('div')
card.className = 'card'
card.innerHTML = `
<form id="invite-form" class="stack">
<input id="invite-label" class="text-input" type="text" placeholder="label (optional, e.g. &quot;for Alice&quot;)" />
<input id="invite-max-uses" class="text-input" type="number" min="1" placeholder="max uses (default 1)" />
<input id="invite-ttl-hours" class="text-input" type="number" min="1" placeholder="expires in hours (default 24)" />
<button type="submit" class="btn btn-primary">create invite</button>
</form>
<div id="invite-create-error"></div>
<div id="invite-created"></div>
<div class="header-row">
<span class="hint">invites</span>
<button type="button" id="refresh-invites" class="btn">refresh</button>
</div>
<div id="invite-list" class="stack"><p class="hint">loading…</p></div>
`
root.appendChild(card)
async function refresh(): Promise<void> {
const listEl = card.querySelector<HTMLElement>('#invite-list')!
const refreshBtn = card.querySelector<HTMLButtonElement>('#refresh-invites')!
refreshBtn.disabled = true
let invites: Invite[]
try {
invites = await adminApi<Invite[]>(password, '/invites')
} finally {
refreshBtn.disabled = false
}
if (invites.length === 0) {
listEl.innerHTML = `<p class="hint">no invites yet</p>`
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 `
<div class="link-box">
<div>${inv.label ? esc(inv.label) : '<span class="hint">(no label)</span>'}${status}</div>
<div class="hint">${inv.used_count}/${inv.max_uses} uses · expires ${fmtTime(inv.expires_at)}</div>
<button type="button" class="btn revoke-btn" data-token="${esc(inv.token)}">revoke</button>
</div>
`
})
.join('')
listEl.querySelectorAll<HTMLButtonElement>('.revoke-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
await adminApi(password, `/invites/${btn.dataset.token}`, { method: 'DELETE' })
refresh()
})
})
}
card.querySelector<HTMLButtonElement>('#refresh-invites')!.addEventListener('click', () => refresh())
const inviteForm = card.querySelector<HTMLFormElement>('#invite-form')!
inviteForm.addEventListener('submit', async (e) => {
e.preventDefault()
const errorEl = card.querySelector<HTMLElement>('#invite-create-error')!
const createdEl = card.querySelector<HTMLElement>('#invite-created')!
errorEl.innerHTML = ''
createdEl.innerHTML = ''
const label = card.querySelector<HTMLInputElement>('#invite-label')!.value.trim() || undefined
const maxUsesRaw = card.querySelector<HTMLInputElement>('#invite-max-uses')!.value
const ttlHoursRaw = card.querySelector<HTMLInputElement>('#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 = `
<div class="stack">
<p class="hint">share this link</p>
<div class="link-box">${esc(url)}</div>
</div>
`
inviteForm.reset()
refresh()
} catch (err) {
const message = err instanceof Error ? err.message : 'failed to create invite'
errorEl.innerHTML = `<div class="error-banner">${message}</div>`
}
})
refresh()
}

View File

@@ -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<string, string> {
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<boolean> {

View File

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

View File

@@ -26,7 +26,23 @@ async function tryFragmentUnlock(root: HTMLElement): Promise<boolean> {
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<boolean> {
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<void> {
if (await tryInviteUnlock(root)) return
if (await tryFragmentUnlock(root)) return
root.innerHTML = ''

72
server/src/admin.ts Normal file
View File

@@ -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 })
})

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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)
}

View File

@@ -25,3 +25,7 @@ export function newBlobId(): string {
export function newConfirmToken(): string {
return randomBase62(128)
}
export function newInviteToken(): string {
return randomBase62(128)
}

View File

@@ -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))

View File

@@ -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 })
})