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
+1252
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "postcard-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Small server for postcard: composited-image storage behind opaque links, plus day-context caption drafting",
"scripts": {
"start": "node --env-file-if-exists=../.env src/index.js",
"dev": "node --env-file-if-exists=../.env --watch src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2"
}
}
+55
View File
@@ -0,0 +1,55 @@
import { config } from './config.js'
// Remix of real fragments from the day, not a free-text model call — per
// PROPOSAL.md's non-goal on LLM-generated captions. Always returns
// something (never an empty field): falls back to generic postcard-voice
// lines when no day-context source is configured or it has nothing for
// that date.
const FALLBACKS = [
'Wish you were here. Mostly wasn\'t.',
'Having a time. More on that later, maybe.',
'Sending this before I talk myself out of it.',
'Quiet one. Thinking of you anyway.',
]
function pick(seedStr, arr) {
let h = 0
for (let i = 0; i < seedStr.length; i++) h = (h * 31 + seedStr.charCodeAt(i)) >>> 0
return arr[h % arr.length]
}
async function fetchDaySummary(dateStr) {
if (!config.daySummaryUrl) return null
try {
const url = `${config.daySummaryUrl}?date=${encodeURIComponent(dateStr)}`
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
if (!res.ok) return null
const data = await res.json() // { git?: string, spotify?: string, note?: string }
// goonk's /api/day-summary returns HTTP 200 with an `error` body for a
// missing/malformed date instead of a non-2xx status — treat that the
// same as "no summary" rather than reading undefined fragments.
if (data && typeof data.error === 'string') return null
return data
} catch {
return null
}
}
const SHAPES = [
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `Spent the day ${git}. ${spotify} the whole time.` },
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `${spotify}. ${git}, mostly.` },
{ needs: ['git'], build: ({ git }) => `Spent the day ${git}.` },
{ needs: ['spotify'], build: ({ spotify }) => `${spotify} all day.` },
{ needs: ['note'], build: ({ note }) => `Been thinking about "${note}."` },
]
export async function draftCaption(dateStr) {
const summary = await fetchDaySummary(dateStr)
const fragments = { git: summary?.git || null, spotify: summary?.spotify || null, note: summary?.note || null }
const usable = SHAPES.filter(shape => shape.needs.every(key => fragments[key]))
if (usable.length === 0) return pick(dateStr, FALLBACKS)
const shape = pick(dateStr + 'shape', usable)
return shape.build(fragments)
}
+25
View 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, matching
// latent/wisp's "deliberately dumb" precedent.
export function sweepExpiredPostcards() {
const now = Math.floor(Date.now() / 1000)
const expired = db.prepare(`SELECT id FROM postcards WHERE expires_at < ?`).all(now)
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
}
if (expired.length > 0) {
db.prepare(`DELETE FROM postcards WHERE expires_at < ?`).run(now)
}
sweepRateLimitWindows()
}
export function startCleanupJob() {
return setInterval(sweepExpiredPostcards, config.cleanupIntervalSeconds * 1000)
}
+22
View File
@@ -0,0 +1,22 @@
import path from 'node:path'
export const config = {
port: Number(process.env.PORT ?? 3098),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
// Shorter than latent's — a postcard link is meant to be opened once by
// the recipient, not browsed later, per PROPOSAL.md's retention question.
ttlHours: Number(process.env.TTL_HOURS ?? 72),
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),
// Optional day-context source for caption drafting — goonk's live
// GET {base}/api/day-summary?date=YYYY-MM-DD -> { git, spotify, note? }.
// No code-level default; set via .env. Left unset, caption generation
// falls back to generic postcard-voice lines.
daySummaryUrl: process.env.DAY_SUMMARY_URL ?? '',
}
+24
View File
@@ -0,0 +1,24 @@
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, 'postcard.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS postcards (
id TEXT PRIMARY KEY,
blob_path TEXT NOT NULL,
mime TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`)
export function blobPath(id) {
return path.join(config.dataDir, 'blobs', id)
}
+55
View File
@@ -0,0 +1,55 @@
// Reverse geocoding for the stamp corner's location line — confirmed via
// PROPOSAL.md's open question that no reverse-geocoding code already
// exists in goonk/rewind/galr to reuse, so this talks to the public
// Nominatim (OpenStreetMap) API directly. No API key required, but its
// usage policy (https://operations.osmfoundation.org/policies/nominatim/)
// caps unauthenticated use at 1 request/sec and requires an identifying
// User-Agent — both enforced below.
// `||` not `??` — docker-compose's environment: block sets this to an
// empty string (not unset) when NOMINATIM_URL isn't provided in .env, and
// an empty string should still fall through to the real default.
const NOMINATIM_URL = process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org/reverse'
const USER_AGENT = 'postcard/0.1 (https://github.com/explewd/postcard)'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // a coordinate's city/country doesn't change
const cache = new Map() // "lat,lon" (rounded) -> { label, ts }
let lastRequestAt = 0
function cacheKey(lat, lon) {
// Rounded to ~1km — plenty for a "City, Country" label, and it turns
// nearby-but-not-identical GPS fixes into cache hits.
return `${lat.toFixed(2)},${lon.toFixed(2)}`
}
function labelFromAddress(address) {
if (!address) return null
const place = address.city || address.town || address.village || address.municipality || address.county
const country = address.country
return [place, country].filter(Boolean).join(', ') || null
}
export async function reverseGeocode(lat, lon) {
const key = cacheKey(lat, lon)
const cached = cache.get(key)
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.label
// Serialize outgoing requests to respect Nominatim's 1 req/sec cap.
const wait = Math.max(0, 1100 - (Date.now() - lastRequestAt))
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait))
lastRequestAt = Date.now()
try {
const url = `${NOMINATIM_URL}?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&zoom=10&addressdetails=1&accept-language=en`
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) return null
const data = await res.json()
const label = labelFromAddress(data.address)
cache.set(key, { label, ts: Date.now() })
return label
} catch {
return null
}
}
+23
View 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 newPostcardId() {
return randomBase62(128)
}
+28
View File
@@ -0,0 +1,28 @@
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 { sweepExpiredPostcards, startCleanupJob } from './cleanup.js'
const app = express()
// Deployed behind a reverse proxy in production — 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))
sweepExpiredPostcards()
startCleanupJob()
app.listen(config.port, () => {
console.log(`postcard server listening on :${config.port}`)
})
+28
View 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, same as latent/wisp.
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)
}
}
+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>`
}