Implement wisp: async encrypted single-retrieval file drop
All checks were successful
Docker / build-and-push (push) Successful in 3m33s
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:
22
server/package.json
Normal file
22
server/package.json
Normal 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
24
server/src/cleanup.ts
Normal 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
15
server/src/config.ts
Normal 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
35
server/src/db.ts
Normal 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
27
server/src/ids.ts
Normal 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
21
server/src/index.ts
Normal 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
123
server/src/routes.ts
Normal 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
14
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user