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

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