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:
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 = ''
|
||||
|
||||
Reference in New Issue
Block a user