Compare commits

...

2 Commits

Author SHA1 Message Date
explewd
266f9708e3 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>
2026-07-12 19:29:46 +02:00
explewd
0b62776282 Add proposal: scoped upload invites (bypass the shared password)
Design doc for a second, narrower credential type alongside
UPLOAD_PASSWORD — time-boxed, use-limited, revocable invite links that
skip the password gate entirely. Self-contained for a fresh agent to
implement: covers the data model, the atomic single-use consumption
race, why invite tokens belong in a query param (not the URL fragment,
unlike the download decryption key), and points at npm-statuspage's
just-built admin-panel pattern as the template for the admin gate.

Not implemented — design stage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:00:16 +02:00
14 changed files with 589 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

217
PROPOSAL-invite-links.md Normal file
View File

@@ -0,0 +1,217 @@
# Proposal: scoped upload invites
**Status:** proposed, not built. This document is self-contained — written
so a fresh agent with no prior conversation context can pick it up and
implement it without needing anything explained first.
## Context (read this first)
wisp is a short-lived, end-to-end encrypted file drop. Full background is
in [README.md](./README.md) and [IMPLEMENTATION.md](./IMPLEMENTATION.md) —
read both before touching code, especially IMPLEMENTATION.md's "deletion
race" section, since this feature must not regress that design.
The relevant existing pieces, for orientation:
- `server/src/config.ts` — env-driven config, including `UPLOAD_PASSWORD`
(required at startup; server refuses to boot without it).
- `server/src/routes.ts``checkUploadPassword()` compares
`X-Upload-Password` header against `config.uploadPassword`. Used by
`POST /auth/check` and `POST /drops`.
- `server/src/db.ts` — SQLite (better-sqlite3, WAL mode) with a `drops`
table and a `confirm_tokens` table (single-use, expiring tokens tied to
a specific drop — the pattern this proposal reuses).
- `server/src/ids.ts``newBlobId()` / `newConfirmToken()`, both 128-bit
random values rendered as base62. Reuse this for invite tokens.
- `client/src/upload.ts``renderUploadPage()` shows the password gate;
`renderUploadForm()` shows the dropzone after a successful
`checkPassword()` call. The unlock state is in-memory only, not
persisted (no cookie, no localStorage) — confirmed in prior testing that
a fresh browser session always re-hits the lock screen.
- `client/src/api.ts``checkPassword()` and `uploadDrop()`, both send
`X-Upload-Password`.
## Motivation
Today there is exactly one credential: `UPLOAD_PASSWORD`, shared by
whoever needs upload access. That has one failure mode worth fixing:
**granting access to one specific person for one specific reason means
either giving them permanent access to the master password, or rotating
the password after they're done (which breaks it for everyone else too).**
There is no way today to say "Alice can upload one file in the next 24
hours" without handing her the same credential that controls the whole
upload gate indefinitely.
This proposal adds **scoped, revocable, time-boxed upload invites** that
bypass the password screen entirely — a second, narrower credential type
alongside the master password, not a replacement for it.
## Design
### Data model
New table in `server/src/db.ts`, same file/pattern as the existing
`drops` and `confirm_tokens` tables:
```sql
CREATE TABLE IF NOT EXISTS upload_invites (
token TEXT PRIMARY KEY, -- 128-bit base62, via newInviteToken() (mirrors newBlobId())
label TEXT, -- optional, e.g. "for Alice" — admin-facing only, never shown to the invite holder
max_uses INTEGER NOT NULL, -- 1 for single-use; higher for "this group can each drop one file"
used_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
```
An invite is valid iff `used_count < max_uses AND expires_at > now()`.
Consuming one increments `used_count` — this must be an atomic
check-and-increment (a single `UPDATE ... WHERE token = ? AND used_count <
max_uses AND expires_at > ?` followed by checking `changes > 0`, not a
separate SELECT-then-UPDATE, to avoid a race between two near-simultaneous
uploads both passing a stale check against the same single-use invite).
### Server-side: `POST /drops` accepts either credential
In `routes.ts`, generalize `checkUploadPassword()` into something like:
```ts
function checkUploadPassword(req): boolean {
return req.get('X-Upload-Password') === config.uploadPassword;
}
function tryConsumeInvite(req): boolean {
const token = req.get('X-Upload-Invite');
if (!token) return false;
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, Math.floor(Date.now() / 1000));
return result.changes > 0;
}
```
`POST /drops` (and `POST /auth/check`, so the client can pre-flight
without burning a use — see below) accepts the request if *either*
`checkUploadPassword(req)` *or* an invite token would validate. The two
checks are independent; an invite never reveals or depends on the master
password.
**Pre-flight without consuming a use**: `checkPassword()`-equivalent for
invites needs a read-only validity check (does this token exist, is it
unexpired, does it have uses left) that does *not* increment `used_count`
— otherwise just loading the upload page with an invite link would burn a
use before the user has chosen a file. Add a separate `GET
/invites/:token/valid` (or fold into `/auth/check` with the token as a
query param) that does the read-only check; only the actual `POST /drops`
call consumes a use, at upload time.
### Client-side: skip the lock screen when a valid invite is present
`client/src/main.ts` currently routes purely on pathname (`/d/:id` vs.
everything else). Add: on the upload path, check
`new URLSearchParams(location.search).get('invite')`. If present, call the
read-only validity endpoint; if valid, call `renderUploadForm(root,
undefined, inviteToken)` directly, skipping `renderUploadPage()`'s
password prompt. `renderUploadForm` needs a second optional credential
parameter (invite token) alongside the existing `password` parameter, and
`uploadDrop()` in `api.ts` needs to send `X-Upload-Invite` instead of (or
alongside — doesn't matter, server checks both) `X-Upload-Password` when
called with an invite.
**Invite tokens go in a query parameter (`?invite=...`), not the URL
fragment.** This is a deliberate, important difference from the
`#k=...` decryption key on download links. The download key must *never*
reach the server — that's the entire point of putting it after `#`. An
invite token is the opposite: the server *must* see it to validate it.
Putting it in the fragment would make it unusable — don't copy the
download-link pattern here by reflex.
### Admin: a separate gate, a separate password
Invites need to be created and revoked somewhere. This should **not**
share `UPLOAD_PASSWORD` — an admin capable of minting invites is a
different trust level than someone who can merely upload. Add
`ADMIN_PASSWORD` (new env var, same "unset = feature disabled entirely"
posture as `UPLOAD_PASSWORD`'s "refuse to start" — except here, unset
should mean "admin endpoints and page return 404/503, everything else
works exactly as it does today," not a hard boot failure, since not every
deployment needs invite management).
This exact pattern — a second password-gated admin surface, disabled
outright if its env var is unset, `X-Admin-Password` header, stateless —
was just built for `npm-statuspage` (sibling project, same author). Look
at that implementation before writing this one:
- `npm-statuspage/src/admin.ts` — router structure, the
`router.use(...)` gate that 401s/503s before every route
- `npm-statuspage/src/adminPage.ts` — inline HTML+JS admin page, no
build step, matches wisp's own lack of... actually wisp *does* have a
Vite client build, unlike npm-statuspage's inline-template-string pages.
Decide whether the invite-admin page lives as a new route in the
existing Vite client (`client/src/admin.ts`, new `renderAdminPage()`,
routed via a new `/admin` path check in `main.ts`) or as a
server-rendered page like npm-statuspage's, served directly by Express
without going through the Vite build. Given wisp already has a client
build pipeline, the Vite-client route is more consistent with wisp's
own conventions than importing npm-statuspage's inline-HTML pattern
wholesale — lean toward extending the existing client, not copying the
other project's page-rendering style.
Admin API surface:
```
POST /api/admin/auth/check
GET /api/admin/invites -- list, with used_count/max_uses/expires_at/label
POST /api/admin/invites -- { label?, maxUses?, ttlSeconds? } -> { token, url }
DELETE /api/admin/invites/:token -- revoke immediately (delete row, or set max_uses = used_count)
```
The create response's `url` should be the fully-formed link
(`https://<host>/#/?invite=<token>` or whatever wisp's actual routing
produces — check `main.ts`'s current path scheme) ready to copy-paste and
send to whoever needs it.
## Security considerations
- An invite token is a bearer credential during its validity window,
exactly like the master password is today — anyone holding a valid,
unexpired, not-yet-exhausted invite link can upload. This is not a new
category of risk; it's a strictly *narrower* one than the master
password, since it's scoped, time-boxed, and independently revocable.
- Revocation is immediate and doesn't affect the master password or other
invites — deleting one row.
- The server can log invite usage (label + timestamp) for basic
accountability. This is a genuine improvement over the shared-password
status quo, where there is no way to tell *which* upload came from
which person — every drop looks identical regardless of which
credential authorized it. Consider recording `used_by_invite: token |
null` on the `drops` row itself (nullable, no new join needed) so a
future admin view could show "this drop came from the invite labeled
'for Alice'."
- Existing upload guardrails (`maxUploadBytes`, whatever rate limiting
exists or gets added) apply identically regardless of which credential
authorized the request — an invite does not bypass size/rate limits,
only the password check.
- Does not touch the deletion-race design in IMPLEMENTATION.md at all —
invites only gate *upload*, not download/confirm/burn, which are
unauthenticated by design (the link itself, including `#k=...`, is
already the credential for retrieval).
## Open questions
- Default `max_uses` when unspecified — 1 (single-use) is the safer
default; requiring an explicit larger number for multi-use invites
makes accidental over-sharing less likely.
- Should invite creation require the admin to already know
`UPLOAD_PASSWORD`, or is `ADMIN_PASSWORD` alone sufficient authority?
Leaning toward: `ADMIN_PASSWORD` alone is sufficient — that's the
entire point of having a separate, narrower-purpose admin credential.
- Whether to expose invite management only via the admin page, or also as
a small CLI/curl-friendly flow for scripting (e.g. minting an invite as
part of some other automation). Not needed for v1; the admin page
covers the actual motivating use case (one-off manual sharing).
- Per-invite size/TTL overrides beyond the global config (e.g. "Alice can
upload up to 500MB, everyone else gets the default 200MB") — likely
out of scope v1, flag as a possible v2 if it comes up.

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