134 lines
4.1 KiB
JavaScript
134 lines
4.1 KiB
JavaScript
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>`
|
||
|
|
}
|