Initial implementation of latent: film-effects editor + develop-later server
All checks were successful
Docker / build-and-push (push) Successful in 3m29s
All checks were successful
Docker / build-and-push (push) Successful in 3m29s
Canvas-based client-side editor (grain, vignette, halation, procedural light leaks, color-cast presets) plus a small Express/SQLite server for the two paths that need persistence: instant share links and delayed "send to develop" links with a randomized reveal time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
25
server/src/cleanup.js
Normal file
25
server/src/cleanup.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs'
|
||||
import { config } from './config.js'
|
||||
import { db, blobPath } from './db.js'
|
||||
import { sweepRateLimitWindows } from './ratelimit.js'
|
||||
|
||||
// Interval-based sweep, not push-based — deletes blobs + rows past
|
||||
// expires_at regardless of whether they were ever viewed, per
|
||||
// PROPOSAL.md's "deliberately dumb" precedent from wisp/keep-watch.
|
||||
export function sweepExpiredImages() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const expired = db.prepare(`SELECT id FROM images WHERE expires_at < ?`).all(now)
|
||||
|
||||
for (const { id } of expired) {
|
||||
fs.rm(blobPath(id), { force: true }, () => {})
|
||||
}
|
||||
if (expired.length > 0) {
|
||||
db.prepare(`DELETE FROM images WHERE expires_at < ?`).run(now)
|
||||
}
|
||||
|
||||
sweepRateLimitWindows()
|
||||
}
|
||||
|
||||
export function startCleanupJob() {
|
||||
return setInterval(sweepExpiredImages, config.cleanupIntervalSeconds * 1000)
|
||||
}
|
||||
24
server/src/config.js
Normal file
24
server/src/config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
|
||||
|
||||
// "Hours to days" per PROPOSAL.md — server picks a develop_at uniformly
|
||||
// at random in this range on every delayed upload.
|
||||
developMinHours: Number(process.env.DEVELOP_MIN_HOURS ?? 2),
|
||||
developMaxHours: Number(process.env.DEVELOP_MAX_HOURS ?? 48),
|
||||
|
||||
// Retention: the instant/share-link path is a casual "just give me a
|
||||
// link" flow, so it gets a much shorter TTL than the delayed path.
|
||||
// Delayed images must stay alive past develop_at with room to actually
|
||||
// go view them, so their TTL is measured from develop_at, not upload time.
|
||||
instantTtlHours: Number(process.env.INSTANT_TTL_HOURS ?? 24),
|
||||
delayedViewWindowHours: Number(process.env.DELAYED_VIEW_WINDOW_HOURS ?? 168), // 7 days after develop_at
|
||||
|
||||
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 20 * 1024 * 1024), // 20MB
|
||||
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
|
||||
|
||||
// Basic IP-based throttle so /api/upload can't become an open file host.
|
||||
rateLimitPerHour: Number(process.env.RATE_LIMIT_PER_HOUR ?? 30),
|
||||
}
|
||||
25
server/src/db.js
Normal file
25
server/src/db.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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, 'latent.db'))
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
blob_path TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
develop_at INTEGER,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
export function blobPath(id) {
|
||||
return path.join(config.dataDir, 'blobs', id)
|
||||
}
|
||||
23
server/src/ids.js
Normal file
23
server/src/ids.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
// 128-bit random value, rendered as base62 — unguessable, since the link
|
||||
// itself is the only credential (no accounts, per PROPOSAL.md).
|
||||
function randomBase62(bits) {
|
||||
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 newImageId() {
|
||||
return randomBase62(128)
|
||||
}
|
||||
29
server/src/index.js
Normal file
29
server/src/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import express from 'express'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { config } from './config.js'
|
||||
import { apiRouter, imageRouter } from './routes.js'
|
||||
import { sweepExpiredImages, startCleanupJob } from './cleanup.js'
|
||||
|
||||
const app = express()
|
||||
|
||||
// Deployed behind a reverse proxy in production (same as the other
|
||||
// single-process toys in this family) — needed for accurate req.ip in
|
||||
// the rate limiter.
|
||||
app.set('trust proxy', true)
|
||||
|
||||
// Mounted before the static frontend and before any body parser: the
|
||||
// upload route reads the raw request stream itself and must not have it
|
||||
// consumed by express.json()/express.raw() first.
|
||||
app.use('/api', apiRouter)
|
||||
app.use('/', imageRouter)
|
||||
|
||||
const staticRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
app.use(express.static(staticRoot))
|
||||
|
||||
sweepExpiredImages()
|
||||
startCleanupJob()
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`latent server listening on :${config.port}`)
|
||||
})
|
||||
28
server/src/ratelimit.js
Normal file
28
server/src/ratelimit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { config } from './config.js'
|
||||
|
||||
// Deliberately dumb in-memory fixed-window counter, not a distributed
|
||||
// rate limiter — this is a single-process toy server, per PROPOSAL.md.
|
||||
const windows = new Map() // ip -> { count, windowStart }
|
||||
|
||||
export function checkRateLimit(ip) {
|
||||
const now = Date.now()
|
||||
const hourMs = 60 * 60 * 1000
|
||||
const entry = windows.get(ip)
|
||||
|
||||
if (!entry || now - entry.windowStart > hourMs) {
|
||||
windows.set(ip, { count: 1, windowStart: now })
|
||||
return true
|
||||
}
|
||||
|
||||
entry.count += 1
|
||||
return entry.count <= config.rateLimitPerHour
|
||||
}
|
||||
|
||||
// Prevents the Map from growing forever across long-running processes.
|
||||
export function sweepRateLimitWindows() {
|
||||
const now = Date.now()
|
||||
const hourMs = 60 * 60 * 1000
|
||||
for (const [ip, entry] of windows) {
|
||||
if (now - entry.windowStart > hourMs) windows.delete(ip)
|
||||
}
|
||||
}
|
||||
150
server/src/routes.js
Normal file
150
server/src/routes.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Router } from 'express'
|
||||
import fs from 'node:fs'
|
||||
import { config } from './config.js'
|
||||
import { db, blobPath } from './db.js'
|
||||
import { newImageId } from './ids.js'
|
||||
import { checkRateLimit } from './ratelimit.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
export const imageRouter = Router()
|
||||
|
||||
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(min + Math.random() * (max - min))
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
apiRouter.post('/upload', (req, res) => {
|
||||
const ip = req.ip
|
||||
if (!checkRateLimit(ip)) {
|
||||
res.status(429).json({ error: 'too many uploads, try again later' })
|
||||
return
|
||||
}
|
||||
|
||||
const mime = (req.get('Content-Type') || '').split(';')[0].trim()
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
res.status(415).json({ error: 'unsupported image type' })
|
||||
return
|
||||
}
|
||||
|
||||
const contentLength = Number(req.get('Content-Length') ?? 0)
|
||||
if (contentLength > config.maxUploadBytes) {
|
||||
res.status(413).json({ error: 'file too large' })
|
||||
return
|
||||
}
|
||||
|
||||
const delayed = req.query.delayed === 'true'
|
||||
const id = newImageId()
|
||||
const dest = blobPath(id)
|
||||
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
|
||||
|
||||
let bytesReceived = 0
|
||||
let aborted = false
|
||||
|
||||
req.on('data', chunk => {
|
||||
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)
|
||||
let developAt = null
|
||||
let expiresAt
|
||||
|
||||
if (delayed) {
|
||||
const hours = randomInt(config.developMinHours, config.developMaxHours)
|
||||
developAt = now + hours * 3600
|
||||
expiresAt = developAt + config.delayedViewWindowHours * 3600
|
||||
} else {
|
||||
expiresAt = now + config.instantTtlHours * 3600
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO images (id, blob_path, mime, created_at, develop_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, dest, mime, now, developAt, expiresAt)
|
||||
|
||||
res.json({
|
||||
id,
|
||||
url: `/i/${id}`,
|
||||
developInHours: delayed ? Math.ceil((developAt - now) / 3600) : null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
imageRouter.get('/i/:id', (req, res) => {
|
||||
const image = db.prepare(`SELECT * FROM images WHERE id = ?`).get(req.params.id)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
if (!image || image.expires_at < now || !fs.existsSync(image.blob_path)) {
|
||||
res.status(404).send(renderPage('not found', '<p>this one\'s gone — wrong link, or it already expired.</p>'))
|
||||
return
|
||||
}
|
||||
|
||||
if (image.develop_at && image.develop_at > now) {
|
||||
const remainingHours = Math.ceil((image.develop_at - now) / 3600)
|
||||
res.status(200).send(renderPage('still developing', developingBody(remainingHours)))
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', image.mime)
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
fs.createReadStream(image.blob_path).pipe(res)
|
||||
})
|
||||
|
||||
// Rounded to a vague bucket rather than an exact countdown — a little
|
||||
// mystery is the point, per PROPOSAL.md's open question on this page.
|
||||
function developingBody(remainingHours) {
|
||||
let phrase
|
||||
if (remainingHours <= 1) phrase = 'any time now'
|
||||
else if (remainingHours <= 6) phrase = 'in a few hours'
|
||||
else if (remainingHours <= 24) phrase = 'later today or tomorrow'
|
||||
else if (remainingHours <= 72) phrase = 'in a couple of days'
|
||||
else phrase = 'in a few days'
|
||||
|
||||
return `
|
||||
<div class="develop-wrap">
|
||||
<div class="fog"></div>
|
||||
<p>still developing — check back <strong>${escapeHtml(phrase)}</strong>.</p>
|
||||
<p class="hint">same link, it'll just work once it's ready.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderPage(title, bodyHtml) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>latent — ${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header-row"><h1>latent</h1></div>
|
||||
<div class="card">${bodyHtml}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
Reference in New Issue
Block a user