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:
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