Implement scoped upload invites
All checks were successful
Docker / build-and-push (push) Successful in 3m9s
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:
@@ -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
|
||||
|
||||
18
README.md
18
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=<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
173
client/src/admin.ts
Normal 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, '&').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 = `<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. "for Alice")" />
|
||||
<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()
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
72
server/src/admin.ts
Normal 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 })
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -25,3 +25,7 @@ export function newBlobId(): string {
|
||||
export function newConfirmToken(): string {
|
||||
return randomBase62(128)
|
||||
}
|
||||
|
||||
export function newInviteToken(): string {
|
||||
return randomBase62(128)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user