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:
@@ -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