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:
Generated
+1252
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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 — ${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>`
|
||||
}
|
||||
Reference in New Issue
Block a user