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

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 = ''