import { Router } from 'express' import fs from 'node:fs' import { config } from './config.js' import { db, blobPath } from './db.js' import { newPostcardId } from './ids.js' import { checkRateLimit } from './ratelimit.js' import { draftCaption } from './caption.js' import { reverseGeocode } from './geocode.js' export const apiRouter = Router() export const imageRouter = Router() const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']) // GET /api/caption?date=YYYY-MM-DD — draft a one-line caption from that // day's context. Editable client-side before it's ever composited in. apiRouter.get('/caption', async (req, res) => { const date = /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : new Date().toISOString().slice(0, 10) const caption = await draftCaption(date) res.json({ caption }) }) // GET /api/geocode?lat=&lon= — "City, Country" for the stamp corner's // location line, only ever called when the uploaded photo had EXIF GPS. // Proxied server-side so the client never talks to Nominatim directly // (keeps the 1req/sec throttling and User-Agent policy in one place). apiRouter.get('/geocode', async (req, res) => { const lat = Number(req.query.lat) const lon = Number(req.query.lon) if (!Number.isFinite(lat) || !Number.isFinite(lon)) { res.status(400).json({ error: 'lat and lon required' }) return } const label = await reverseGeocode(lat, lon) res.json({ label }) }) 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 id = newPostcardId() 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) const expiresAt = now + config.ttlHours * 3600 db.prepare( `INSERT INTO postcards (id, blob_path, mime, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, ).run(id, dest, mime, now, expiresAt) res.json({ id, url: `/p/${id}` }) }) }) // No develop-later gate — this app's whole point is immediacy, unlike latent. imageRouter.get('/p/:id', (req, res) => { const postcard = db.prepare(`SELECT * FROM postcards WHERE id = ?`).get(req.params.id) const now = Math.floor(Date.now() / 1000) if (!postcard || postcard.expires_at < now || !fs.existsSync(postcard.blob_path)) { res.status(404).send(renderPage('not found', '

this one\'s gone — wrong link, or it already expired.

')) return } res.setHeader('Content-Type', postcard.mime) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') fs.createReadStream(postcard.blob_path).pipe(res) }) function escapeHtml(s) { return s.replace(/&/g, '&').replace(//g, '>') } function renderPage(title, bodyHtml) { return ` postcard — ${escapeHtml(title)}

postcard

${bodyHtml}
` }