Initial commit: postcard v1

Phone photo -> composited postcard (canvas templates, stamp corner,
EXIF-aware date/location, self-hosted handwriting font) with a small
server for share links, day-context caption drafting off goonk's
day-summary API, and Nominatim reverse geocoding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-06 19:49:03 +02:00
co-authored by Claude Sonnet 5
commit cf0f5fca2c
24 changed files with 2839 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
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', '<p>this one\'s gone — wrong link, or it already expired.</p>'))
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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>postcard &mdash; ${escapeHtml(title)}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<div class="header-row"><h1>postcard</h1></div>
<div class="card">${bodyHtml}</div>
</div>
</body>
</html>`
}