Compare commits
2 Commits
121dbe81f1
...
266f9708e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266f9708e3 | ||
|
|
0b62776282 |
@@ -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
217
PROPOSAL-invite-links.md
Normal 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.
|
||||
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