Implement wisp: async encrypted single-retrieval file drop
All checks were successful
Docker / build-and-push (push) Successful in 3m33s

Client-side libsodium encryption with the key in the URL fragment, an
Express/SQLite server holding ciphertext until a confirm-token round trip
proves successful decrypt (avoiding the delete-on-first-byte race), TTL
sweep for unclaimed drops, and a password-gated upload UI styled to match
flit. Dockerized to match the project family's conventions, with a named
volume so the DB/blobs survive redeploys, and a Gitea Actions workflow to
build and push the image.
This commit is contained in:
explewd
2026-07-09 19:14:54 +02:00
parent 8fd73155bd
commit 22619ebd11
30 changed files with 4412 additions and 0 deletions

22
server/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "wisp-server",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "tsx watch --env-file=../.env src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node --env-file=../.env dist/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/node": "^22.7.4",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}

24
server/src/cleanup.ts Normal file
View File

@@ -0,0 +1,24 @@
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
// Sweeps expired-but-never-confirmed drops. The TTL is the backstop for
// links nobody ever opens; confirmed downloads are deleted immediately by
// the confirm endpoint instead.
export function sweepExpiredDrops(): void {
const now = Math.floor(Date.now() / 1000)
const expired = db
.prepare(`SELECT id FROM drops WHERE expires_at < ? AND burned = 0`)
.all(now) as { id: string }[]
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
db.prepare(`UPDATE drops SET burned = 1 WHERE id = ?`).run(id)
}
db.prepare(`DELETE FROM confirm_tokens WHERE expires_at < ?`).run(now)
}
export function startCleanupJob(): NodeJS.Timeout {
return setInterval(sweepExpiredDrops, config.cleanupIntervalSeconds * 1000)
}

15
server/src/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from 'node:path'
if (!process.env.UPLOAD_PASSWORD) {
console.error('UPLOAD_PASSWORD is not set — refusing to start with an open upload gate.')
process.exit(1)
}
export const config = {
port: Number(process.env.PORT ?? 3000),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
ttlSeconds: Number(process.env.TTL_SECONDS ?? 60 * 60 * 24 * 3), // 3 days
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),
}

35
server/src/db.ts Normal file
View File

@@ -0,0 +1,35 @@
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
import { config } from './config.js'
fs.mkdirSync(config.dataDir, { recursive: true })
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
export const db = new Database(path.join(config.dataDir, 'wisp.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS drops (
id TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
downloaded_at INTEGER,
burned INTEGER NOT NULL DEFAULT 0
);
-- Single-use tokens issued at download time, redeemed by the confirm
-- endpoint. Kept in SQLite (not memory) so a server restart between
-- download and confirm doesn't strand the client with an unredeemable
-- token.
CREATE TABLE IF NOT EXISTS confirm_tokens (
token TEXT PRIMARY KEY,
drop_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
`)
export function blobPath(id: string): string {
return path.join(config.dataDir, 'blobs', id)
}

27
server/src/ids.ts Normal file
View File

@@ -0,0 +1,27 @@
import { randomBytes } from 'node:crypto'
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
// 128-bit random value, rendered as base62 — matches the blob-id shape
// described in IMPLEMENTATION.md (unguessable, distinct from the key).
function randomBase62(bits: number): string {
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
let value = 0n
for (const b of bytes) value = (value << 8n) | BigInt(b)
let out = ''
const base = BigInt(BASE62.length)
while (value > 0n) {
out = BASE62[Number(value % base)] + out
value /= base
}
return out.padStart(Math.ceil((bits / Math.log2(62))), '0')
}
export function newBlobId(): string {
return randomBase62(128)
}
export function newConfirmToken(): string {
return randomBase62(128)
}

21
server/src/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from './config.js'
import { router } from './routes.js'
import { startCleanupJob, sweepExpiredDrops } from './cleanup.js'
const app = express()
app.use('/api', router)
const clientDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../client/dist')
app.use(express.static(clientDist))
app.get('*', (_req, res) => res.sendFile(path.join(clientDist, 'index.html')))
sweepExpiredDrops()
startCleanupJob()
app.listen(config.port, () => {
console.log(`wisp server listening on :${config.port}`)
})

123
server/src/routes.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Router } from 'express'
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { newBlobId, newConfirmToken } from './ids.js'
export const router = Router()
function checkUploadPassword(req: import('express').Request): boolean {
return req.get('X-Upload-Password') === config.uploadPassword
}
// Lets the client gate the upload UI behind a password without uploading
// anything yet.
router.post('/auth/check', (req, res) => {
res.json({ ok: checkUploadPassword(req) })
})
router.post('/drops', (req, res) => {
if (!checkUploadPassword(req)) {
res.status(401).json({ error: 'bad password' })
return
}
const contentLength = Number(req.get('Content-Length') ?? 0)
if (contentLength > config.maxUploadBytes) {
res.status(413).json({ error: 'file too large' })
return
}
const id = newBlobId()
const dest = blobPath(id)
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
let bytesReceived = 0
let aborted = false
req.on('data', (chunk: Buffer) => {
bytesReceived += chunk.length
if (bytesReceived > config.maxUploadBytes) {
aborted = true
writeStream.destroy()
req.destroy()
}
})
req.pipe(writeStream)
writeStream.on('error', () => {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
})
writeStream.on('finish', () => {
if (aborted) {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
return
}
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)
res.json({ id, ttl: config.ttlSeconds })
})
})
router.get('/drops/:id', (req, res) => {
const drop = db
.prepare(`SELECT * FROM drops WHERE id = ?`)
.get(req.params.id) as
| { id: string; burned: number; expires_at: number }
| undefined
if (!drop || drop.burned || drop.expires_at < Math.floor(Date.now() / 1000)) {
res.status(404).json({ error: 'not found' })
return
}
const path = blobPath(drop.id)
if (!fs.existsSync(path)) {
res.status(404).json({ error: 'not found' })
return
}
const token = newConfirmToken()
const tokenExpiresAt = Math.floor(Date.now() / 1000) + 60 * 15
db.prepare(
`INSERT INTO confirm_tokens (token, drop_id, expires_at) VALUES (?, ?, ?)`,
).run(token, drop.id, tokenExpiresAt)
res.setHeader('X-Confirm-Token', token)
res.setHeader('Content-Type', 'application/octet-stream')
fs.createReadStream(path).pipe(res)
})
router.post('/drops/:id/confirm', (req, res) => {
const auth = req.get('Authorization') ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : ''
const row = db
.prepare(`SELECT * FROM confirm_tokens WHERE token = ?`)
.get(token) as { token: string; drop_id: string; expires_at: number } | undefined
if (!row || row.drop_id !== req.params.id || row.expires_at < Math.floor(Date.now() / 1000)) {
res.status(400).json({ error: 'invalid or expired confirm token' })
return
}
// Single-use: delete the token before doing anything else.
db.prepare(`DELETE FROM confirm_tokens WHERE token = ?`).run(token)
fs.rm(blobPath(row.drop_id), { force: true }, () => {})
db.prepare(`UPDATE drops SET burned = 1, downloaded_at = ? WHERE id = ?`).run(
Math.floor(Date.now() / 1000),
row.drop_id,
)
res.json({ ok: true })
})

14
server/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}