commit cf0f5fca2cb173f79b5a7166dcff18c6e6b6e280 Author: Fredrik Johansson Date: Thu Aug 6 19:49:03 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..515207a --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to .env and adjust. Read two ways: `npm start`/`npm run dev` in +# server/ load it directly via Node's --env-file-if-exists; `docker +# compose up` reads it as compose's env file instead (IMAGE/HOST_PORT are +# only meaningful there, the rest get passed through to the container). + +# Image to deploy — set by CI/CD normally; only needed for a manual +# docker compose up. +IMAGE=repo.explewd.com/explewd/postcard:latest + +# Host port to expose (container always listens on 3098). +HOST_PORT=3098 + +# Retention — a postcard link is meant to be opened once by the +# recipient, not browsed later, so this is shorter than latent's. +TTL_HOURS=72 + +MAX_UPLOAD_BYTES=20971520 +RATE_LIMIT_PER_HOUR=30 + +# Optional day-context source for caption drafting. GET {url}?date=YYYY-MM-DD +# must return { git?, spotify?, note? }. Left unset, captions fall back to +# generic postcard-voice lines instead of invented personal data. +DAY_SUMMARY_URL=https://example.com/api/day-summary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9adcd8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.env +data +server/data +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4c7000 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:24-slim + +# better-sqlite3 has a native addon with no prebuilt binary for this +# platform/Node combo yet — build it from source, then drop the toolchain. +RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY server/package*.json server/ +RUN npm install --prefix server --omit=dev \ + && apt-get purge -y python3 make g++ && apt-get autoremove -y + +COPY server/src/ server/src/ +COPY index.html style.css app.js exif.js favicon.svg ./ +COPY fonts/ fonts/ + +# Do NOT copy data/ — mutable runtime volume holding the SQLite db and +# image blobs, would get clobbered on every redeploy if baked in. +ENV NODE_ENV=production +ENV PORT=3098 +ENV DATA_DIR=/app/data + +EXPOSE 3098 + +CMD ["node", "server/src/index.js"] diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..0904222 --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,143 @@ +# Proposal: `postcard` — turn a phone photo into a sendable postcard + +**Status:** proposed, not built. Self-contained — written so a fresh +agent with no prior conversation context can pick this up and implement +it without anything explained first. No repo exists yet; this file is +the seed of one. + +## Motivation + +Upload a photo from your phone (or any device), it gets composited onto +a vintage postcard template — border, stamp corner, a handwritten-style +caption — and you get a shareable link or a downloadable image. The +caption is auto-drafted from whatever context is available for that +day (git activity, what was playing, a note title) so the postcard reads +like it was actually written *from* that day, not just a filter applied +to a photo. + +Structurally this is `latent`'s upload → transform → deliver shape, with +the transform swapped from film-emulation filters to postcard +compositing, and one addition: a short generated caption line instead of +(or alongside) canvas filters. Reuse `latent`'s validated pieces +wholesale rather than re-deriving them — same phone-upload flow, same +blob+SQLite+TTL server, same no-accounts/link-is-the-credential model. + +## Architecture + +Static/client-heavy frontend (canvas compositing, same "no build step" +family as `latent`/`typo`/`flit`/`wisp`) plus a small single-process +server, copied from `latent/server` almost unchanged: `db.js` (SQLite +metadata), `ids.js` (opaque random IDs), `cleanup.js` (interval TTL +sweep), `ratelimit.js` (basic IP throttle on upload), `routes.js`, +`index.js`. No accounts, no auth — the link is the credential, same as +`latent`/`wisp`. + +### Editing (client-side, ``) + +1. **Upload** — `` for direct camera access, + normal file picker as fallback (same open question `latent` already + flagged, same answer: ship both). +2. **Template** — a handful of postcard border/stamp-corner presets + (plain cream border, torn-edge, faded color-photo-era border) — pick + a vibe, not a full editor. Reuse `latent`'s "presets, not sliders" + call for the same reason: faster to ship, more toy than tool. +3. **Caption** — an editable text field, pre-filled by a generated draft + (see below), rendered onto the card in a handwriting-style webfont. + Editable because auto-drafted text should be a starting point, not + the final word — the person sending it should be able to make it + actually theirs. +4. **Postmark/stamp corner** — today's date and a small generated + "location" stamp if EXIF GPS is present (round-tripped through the + same reverse-geocoding approach `rewind`/`galr` already use, if one + exists — otherwise just the date, no invented location). +5. Compositing is canvas-based, non-destructive within the session + (recompute from the original photo + a param object, matching + `latent`'s pattern) so switching templates doesn't require + re-uploading. + +### Caption generation + +The interesting new piece `latent` didn't need. Draft a one-line caption +from whatever of these is available and non-empty for the photo's +date (EXIF date if present, else upload date): + +- Git activity that day (via the same Gitea-API aggregate-count approach + `rewind` already built — "spent the day untangling a deploy script" / + "quiet one, no commits") +- What was playing (Spotify plays log, same source `/wrapped`/`/now` + already read — "[artist] on repeat") +- A note/blog title from that date, if one exists + +This is explicitly a **remix of existing fragments**, not a generative +free-text model call — same spirit as the haiku/found-poetry idea +below: assemble from real fragments of that day, phrase them into one +short sentence with a small set of template shapes ("Spent the day +{git_fragment}. {spotify_fragment} the whole time." / +"{spotify_fragment}. {git_fragment}, mostly."), and always leave it +editable. If none of the three sources have data for that date, fall +back to a small set of generic postcard-voice lines ("Wish you were +here. Mostly wasn't.") rather than leaving the field empty. + +### Finishing paths + +Same two-path split as `latent`: + +1. **Instant** — composite, then download directly, entirely + client-side, no server touched. Optional "generate share link" + (explicit action, not automatic). +2. **Send** — uploads the composited image to server storage, returns a + link. No delayed-reveal mechanic here (that's `latent`'s gimmick, not + this one) — the postcard metaphor's own delay is real mail taking a + few days to arrive, which the link doesn't need to fake. + +### Server + +Copied from `latent/server`'s shape almost directly: + +**Table `postcards`:** + +| column | type | notes | +|---|---|---| +| `id` | text PK | random opaque ID, used in the URL | +| `blob_path` | text | composited image on disk | +| `mime` | text | | +| `created_at` | integer | | +| `expires_at` | integer | TTL sweep target, same as `latent` | + +**Endpoints:** + +- `POST /api/upload` — body: composited image bytes. Returns `{ id, url }`. +- `GET /p/:id` — serves the postcard image directly (no develop-later + gate — this app's whole point is immediacy, unlike `latent`). +- TTL sweep, same interval-based pattern as `latent`/`wisp`. + +No auth. Rate-limit `/api/upload` the same way `latent` does. + +## Non-goals + +- No accounts, no "your postcards" history — every card stands alone via + its own link, same throwaway philosophy as `latent`/`wisp`. +- No delayed-reveal mechanic — that's `latent`'s gimmick; this one's + point is speed, not anticipation. +- No free-text LLM-generated captions — template-and-fragment assembly + from real personal data only, kept editable, never presented as if the + app "wrote" it. +- No collaborative/multi-recipient cards — one photo, one card, one link. + +## Open questions + +- **Where the day-context lookups live** — `rewind` and `/wrapped`/`/now` + already have working Gitea-API and Spotify-log integrations; decide + whether `postcard`'s server calls those APIs directly (network + dependency on goonk's API being reachable) or whether this only makes + sense as a goonk-hosted feature rather than a fully separate sibling + project. Worth resolving before writing `routes.js`. +- **Location stamp source** — confirm whether `galr`/`rewind` already do + reverse geocoding anywhere reusable, or whether this ships without a + location stamp in a first pass. +- **Handwriting webfont choice and licensing** — pick one, check its + license allows self-hosting. +- **Image size limits** — same question `latent` flagged, same likely + answer (downscale on upload, cap stored size). +- **Retention window** — shorter than `latent`'s, probably — a postcard + link is meant to be opened once by the recipient, not browsed later. diff --git a/README.md b/README.md new file mode 100644 index 0000000..304b243 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# postcard + +Turn a phone photo into a sendable postcard. Upload a photo, it gets +composited onto a vintage postcard template (border, stamp corner, +handwriting-style caption) entirely in the browser, then either download +it right away or upload it for a shareable link. + +The caption is auto-drafted by assembling real fragments of that day +(git activity, what was playing, a note title) via `server/src/caption.js` +— never a free-text model call — and stays editable. With no day-context +source configured it falls back to a small set of generic postcard-voice +lines. + +Structurally this is a copy of [`latent`](../latent)'s upload → transform +→ deliver shape and server (`db.js`/`ids.js`/`cleanup.js`/`ratelimit.js`/ +`routes.js`), with the transform swapped for postcard compositing. See +[PROPOSAL.md](PROPOSAL.md) for the original design writeup. + +## Two finishing paths off one editor + +1. **Instant** — composite, then download directly, entirely client-side. + Optionally generate a share link (explicit action, not automatic). +2. **Send** — uploads the composited image to server storage, returns a + link immediately (no delayed-reveal gate — that's `latent`'s gimmick, + not this one's). + +## Running locally + +``` +npm install --prefix server +npm start --prefix server +``` + +Then open http://localhost:3098. `server`'s `start`/`dev` scripts load +`../.env` automatically via Node's `--env-file-if-exists` — copy +`.env.example` to `.env` at the repo root and adjust. `DATA_DIR` defaults +to `./server/data` (gitignored) for the SQLite db and image blobs. + +## Deploying + +`docker compose up` using the provided `docker-compose.yml` (copy +`.env.example` to `.env` and adjust — `IMAGE`/`HOST_PORT` are only read by +compose, the rest are passed through to the container). + +## Config + +All via env vars: + +- `TTL_HOURS` — link retention (default 72h; shorter than `latent`'s, + since a postcard link is meant to be opened once, not browsed later). +- `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no + accounts or auth on any endpoint, the link itself is the credential. +- `DAY_SUMMARY_URL` — optional, no code-level default (set it in `.env`). + If set, `GET {url}?date=YYYY-MM-DD` is expected to return + `{ git?, spotify?, note? }` fragments for that date. `.env` here points + it at `https://goonk.se/api/day-summary`, goonk's live endpoint + (confirmed returning `{ git, spotify? }` — no `note` field observed yet). + A response body containing an `error` key (goonk's shape for a + missing/malformed date) is treated the same as no summary. Left unset, + captions fall back to generic lines — no invented personal data. +- `NOMINATIM_URL` — reverse-geocoding endpoint for the stamp corner's + location line (only queried when a photo has EXIF GPS). Defaults to the + public `nominatim.openstreetmap.org`; `server/src/geocode.js` throttles + to 1 req/sec and caches by rounded coordinate per that API's usage + policy. No API key needed. + +## Notable implementation details + +- EXIF is parsed client-side with no dependency (`exif.js`) — just enough + to read `DateTimeOriginal` and GPS lat/lon out of a JPEG. Parse + failures/missing tags fall back to upload date / no location stamp, + never invented data. +- The caption's handwriting font is self-hosted (`fonts/caveat-latin.woff2`, + Caveat, SIL OFL — see `fonts/OFL.txt`), not loaded from + fonts.googleapis.com at runtime. +- The upload card offers separate "take a photo" (camera capture) and + "choose from library" buttons rather than relying on the OS's file + picker to offer both — some mobile browsers skip the chooser and go + straight to the camera when `capture` is set on a single input. + +## Non-goals + +No accounts, no "your postcards" history, no delayed-reveal mechanic, no +free-text LLM-generated captions, no collaborative/multi-recipient cards. diff --git a/app.js b/app.js new file mode 100644 index 0000000..caecb23 --- /dev/null +++ b/app.js @@ -0,0 +1,376 @@ +(() => { + const fileInput = document.getElementById('file-input'); + const cameraInput = document.getElementById('camera-input'); + const cameraBtn = document.getElementById('camera-btn'); + const libraryBtn = document.getElementById('library-btn'); + const dropZone = document.getElementById('drop-zone'); + const dropCard = document.getElementById('drop-card'); + const editorCard = document.getElementById('editor-card'); + const canvas = document.getElementById('canvas'); + const ctx = canvas.getContext('2d'); + + const templateRow = document.getElementById('template-row'); + const captionInput = document.getElementById('caption-input'); + const captionStatus = document.getElementById('caption-status'); + const redraftBtn = document.getElementById('redraft-btn'); + + const newPhotoBtn = document.getElementById('new-photo-btn'); + const downloadBtn = document.getElementById('download-btn'); + const shareBtn = document.getElementById('share-btn'); + const resultPanel = document.getElementById('result-panel'); + + const source = document.createElement('canvas'); // original photo, cover-cropped into the photo area + const sourceCtx = source.getContext('2d'); + + let originalImg = null; + let photoDate = new Date(); + let locationLabel = null; // "City, Country" from EXIF GPS + server-side reverse geocode, or null + + const params = { + template: 'cream', + caption: '', + }; + + // Registered via CSS @font-face but canvas fillText won't wait for a + // lazily-loaded webfont on its own — load it explicitly once up front. + let fontReady = false; + document.fonts.load('700 40px Caveat').catch(() => {}).finally(() => { + fontReady = true; + if (originalImg) render(); + }); + + // Small seeded PRNG so a template's jagged/faded look stays fixed per + // photo instead of jittering every redraw. + function mulberry32(seed) { + let a = Math.floor(seed * 0xffffffff); + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + let torSeed = Math.random(); + + async function loadFile(file) { + if (!file || !file.type.startsWith('image/')) return; + + // Read EXIF before the image element even loads — DateTimeOriginal + // backdates the caption/postmark to when the photo was actually taken, + // not when it happened to be uploaded; GPS (if present) drives the + // location line. Both are optional: parse failures/missing tags just + // mean "fall back to upload date, no location," per PROPOSAL.md. + const exif = window.PostcardExif ? await window.PostcardExif.read(file) : null; + photoDate = exif?.date || new Date(); + locationLabel = null; + + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + originalImg = img; + URL.revokeObjectURL(url); + dropCard.hidden = true; + editorCard.hidden = false; + resultPanel.hidden = true; + torSeed = Math.random(); + rebuildSource(); + render(); + draftCaption(); + if (exif && exif.lat != null && exif.lon != null) lookupLocation(exif.lat, exif.lon); + }; + img.src = url; + } + + async function lookupLocation(lat, lon) { + try { + const res = await fetch(`/api/geocode?lat=${lat}&lon=${lon}`); + const data = await res.json(); + if (data.label) { + locationLabel = data.label; + render(); + } + } catch { + // No location line — never invents one, per PROPOSAL.md. + } + } + + // Builds `source`: the photo cover-cropped to a fixed postcard photo-area + // aspect (3:2, classic postcard proportions), capped so a raw phone photo + // doesn't choke canvas ops. + function rebuildSource() { + if (!originalImg) return; + const iw = originalImg.naturalWidth; + const ih = originalImg.naturalHeight; + const targetAspect = 3 / 2; + + let cropW = iw, cropH = ih, cropX = 0, cropY = 0; + const currentAspect = iw / ih; + if (currentAspect > targetAspect) { + cropW = Math.round(ih * targetAspect); + cropX = Math.round((iw - cropW) / 2); + } else { + cropH = Math.round(iw / targetAspect); + cropY = Math.round((ih - cropH) / 2); + } + + const maxDim = 1800; + let outW = cropW, outH = cropH; + if (Math.max(outW, outH) > maxDim) { + const scale = maxDim / Math.max(outW, outH); + outW = Math.round(outW * scale); + outH = Math.round(outH * scale); + } + + source.width = outW; + source.height = outH; + sourceCtx.clearRect(0, 0, outW, outH); + sourceCtx.drawImage(originalImg, cropX, cropY, cropW, cropH, 0, 0, outW, outH); + + // Full card = photo area + a border margin all around, matching a + // physical postcard's white/cream frame. + const margin = Math.round(outW * 0.06); + canvas.width = outW + margin * 2; + canvas.height = outH + margin * 2; + } + + function render() { + if (!originalImg) return; + const w = canvas.width; + const h = canvas.height; + const margin = Math.round(source.width * 0.06); + + ctx.clearRect(0, 0, w, h); + + // Border background per template. + if (params.template === 'faded') { + ctx.fillStyle = '#e9dfc8'; + } else { + ctx.fillStyle = '#f7f2e6'; + } + ctx.fillRect(0, 0, w, h); + + // Photo, with a per-template filter applied at draw time. + ctx.save(); + ctx.filter = params.template === 'faded' + ? 'sepia(0.25) saturate(0.55) contrast(0.85) brightness(1.1)' + : 'none'; + if (params.template === 'torn') { + clipTornRect(margin, margin, source.width, source.height, torSeed); + } + ctx.drawImage(source, margin, margin); + ctx.restore(); + + if (params.template !== 'torn') { + ctx.strokeStyle = 'rgba(0,0,0,0.15)'; + ctx.lineWidth = 1; + ctx.strokeRect(margin + 0.5, margin + 0.5, source.width - 1, source.height - 1); + } + + drawStampCorner(w, h, margin); + drawCaption(w, h, margin); + } + + // Clips the current path to a rectangle with a hand-torn edge — jagged + // teeth along each side, seeded so it's stable per photo. + function clipTornRect(x, y, w, h, seed) { + const rng = mulberry32(seed); + const tooth = Math.max(2, Math.round(Math.min(w, h) * 0.006)); + const step = tooth * 3; + + ctx.beginPath(); + let px = x, py = y; + ctx.moveTo(px, py); + for (; px < x + w; px += step) ctx.lineTo(px, y + (rng() - 0.5) * tooth); + ctx.lineTo(x + w, y + (rng() - 0.5) * tooth); + for (; py < y + h; py += step) ctx.lineTo(x + w + (rng() - 0.5) * tooth, py); + ctx.lineTo(x + w + (rng() - 0.5) * tooth, y + h); + for (px = x + w; px > x; px -= step) ctx.lineTo(px, y + h + (rng() - 0.5) * tooth); + ctx.lineTo(x, y + h + (rng() - 0.5) * tooth); + for (py = y + h; py > y; py -= step) ctx.lineTo(x + (rng() - 0.5) * tooth, py); + ctx.closePath(); + ctx.clip(); + } + + // Postmark stamp — date always, plus a location line only when the photo + // had EXIF GPS and reverse geocoding succeeded. Never an invented + // location, per PROPOSAL.md's fallback for "no EXIF GPS." + function drawStampCorner(w, h, margin) { + const size = margin * 1.6; + // Right edge of the (rotated) box must stay inside the card, not just + // inside the photo area — the border margin is the only safe zone. + const x = w - margin - size; + const y = margin * 0.15; + + ctx.save(); + ctx.translate(x, y); + ctx.rotate((6 * Math.PI) / 180); + ctx.strokeStyle = 'rgba(0,0,0,0.35)'; + ctx.lineWidth = Math.max(1, size * 0.02); + ctx.setLineDash([size * 0.04, size * 0.03]); + ctx.strokeRect(0, 0, size, size * 0.8); + ctx.setLineDash([]); + + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.textAlign = 'center'; + const dateStr = photoDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + + if (locationLabel) { + ctx.font = `${Math.round(size * 0.1)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; + ctx.fillText(truncate(locationLabel, 18), size / 2, size * 0.3); + ctx.font = `${Math.round(size * 0.11)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; + ctx.fillText(dateStr, size / 2, size * 0.52); + ctx.font = `${Math.round(size * 0.08)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; + ctx.fillText('postcard', size / 2, size * 0.7); + } else { + ctx.font = `${Math.round(size * 0.13)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; + ctx.fillText(dateStr, size / 2, size * 0.45); + ctx.font = `${Math.round(size * 0.09)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; + ctx.fillText('postcard', size / 2, size * 0.65); + } + ctx.restore(); + } + + function truncate(str, maxLen) { + return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str; + } + + function drawCaption(w, h, margin) { + if (!params.caption) return; + ctx.save(); + ctx.fillStyle = 'rgba(30,25,15,0.85)'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + // Caveat reads smaller than a sans face at the same px size (thin + // connected strokes), so it gets a bigger multiplier and bolder weight + // than a plain cursive fallback would need. + const fontSize = Math.max(16, Math.round(margin * 0.55)); + ctx.font = fontReady + ? `700 ${fontSize}px "Caveat", cursive` + : `${Math.round(fontSize * 0.75)}px "Segoe Script", "Bradley Hand", cursive`; + wrapText(params.caption, margin, h - margin * 0.35, w - margin * 2, fontSize * 1.15); + ctx.restore(); + } + + function wrapText(text, x, bottomY, maxWidth, lineHeight) { + const words = text.split(/\s+/); + const lines = []; + let line = ''; + for (const word of words) { + const test = line ? `${line} ${word}` : word; + if (ctx.measureText(test).width > maxWidth && line) { + lines.push(line); + line = word; + } else { + line = test; + } + } + if (line) lines.push(line); + const clipped = lines.slice(-3); // at most 3 lines, bottom-anchored + const startY = bottomY - (clipped.length - 1) * lineHeight; + clipped.forEach((l, i) => ctx.fillText(l, x, startY + i * lineHeight)); + } + + async function draftCaption() { + captionStatus.textContent = 'drafting…'; + try { + const dateStr = photoDate.toISOString().slice(0, 10); + const res = await fetch(`/api/caption?date=${dateStr}`); + const data = await res.json(); + captionInput.value = data.caption || ''; + params.caption = captionInput.value; + captionStatus.textContent = ''; + render(); + } catch { + captionStatus.textContent = ''; + } + } + + function showResult(html, isError) { + resultPanel.innerHTML = html; + resultPanel.classList.toggle('error', !!isError); + resultPanel.hidden = false; + } + + function canvasToBlob() { + return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92)); + } + + async function upload() { + shareBtn.disabled = true; + showResult('uploading…'); + try { + const blob = await canvasToBlob(); + const res = await fetch('/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'image/jpeg' }, + body: blob, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `upload failed (${res.status})`); + } + const data = await res.json(); + const link = `${location.origin}${data.url}`; + showResult(`share link: ${link}`); + } catch (err) { + showResult(err.message || 'something went wrong', true); + } finally { + shareBtn.disabled = false; + } + } + + fileInput.addEventListener('change', () => loadFile(fileInput.files[0])); + cameraInput.addEventListener('change', () => loadFile(cameraInput.files[0])); + cameraBtn.addEventListener('click', () => cameraInput.click()); + libraryBtn.addEventListener('click', () => fileInput.click()); + + ['dragover', 'dragenter'].forEach(evt => + dropZone.addEventListener(evt, e => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }) + ); + ['dragleave', 'dragend', 'drop'].forEach(evt => + dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over')) + ); + dropZone.addEventListener('drop', e => { + e.preventDefault(); + loadFile(e.dataTransfer.files[0]); + }); + + [...templateRow.children].forEach(btn => { + btn.addEventListener('click', () => { + params.template = btn.dataset.template; + [...templateRow.children].forEach(b => b.classList.toggle('active', b === btn)); + render(); + }); + }); + + captionInput.addEventListener('input', () => { + params.caption = captionInput.value; + render(); + }); + redraftBtn.addEventListener('click', draftCaption); + + newPhotoBtn.addEventListener('click', () => { + originalImg = null; + locationLabel = null; + fileInput.value = ''; + cameraInput.value = ''; + editorCard.hidden = true; + dropCard.hidden = false; + resultPanel.hidden = true; + }); + + downloadBtn.addEventListener('click', async () => { + const blob = await canvasToBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `postcard-${Date.now()}.jpg`; + a.click(); + URL.revokeObjectURL(url); + }); + + shareBtn.addEventListener('click', upload); +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1092ba8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + app: + image: ${IMAGE} + container_name: postcard + restart: unless-stopped + pull_policy: always + ports: + - "${HOST_PORT:-3098}:3098" + environment: + - TTL_HOURS=${TTL_HOURS:-72} + - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-20971520} + - RATE_LIMIT_PER_HOUR=${RATE_LIMIT_PER_HOUR:-30} + # Day-context source for caption drafting — see README. Left unset, + # captions fall back to generic postcard-voice lines. + - DAY_SUMMARY_URL=${DAY_SUMMARY_URL:-} + # Reverse-geocoding endpoint for the stamp corner's location line. + # Defaults to the public Nominatim instance if unset. + - NOMINATIM_URL=${NOMINATIM_URL:-} + volumes: + # Named volume, not a bind mount into the build context — the SQLite + # db and image blobs live here and must survive image redeploys. + - postcard-data:/app/data + +volumes: + postcard-data: diff --git a/exif.js b/exif.js new file mode 100644 index 0000000..bad9004 --- /dev/null +++ b/exif.js @@ -0,0 +1,117 @@ +// Minimal EXIF reader — just enough to pull DateTimeOriginal and GPS lat/lon +// out of a JPEG, with no dependency (this project's "no build step" family +// doesn't pull in npm packages for the client). Not a general-purpose EXIF +// library: unsupported/malformed data is treated as "nothing found," never +// thrown, since the caller always has upload-date/no-location fallbacks. +window.PostcardExif = (() => { + const TYPE_SIZES = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }; + + function readValue(view, type, count, offset, littleEndian) { + // Only the types actually used by the tags we read (ASCII, SHORT, + // LONG, RATIONAL) — anything else returns null rather than guessing. + if (type === 2) { // ASCII + let s = ''; + for (let i = 0; i < count - 1; i++) s += String.fromCharCode(view.getUint8(offset + i)); + return s; + } + if (type === 3) return view.getUint16(offset, littleEndian); // SHORT + if (type === 4) return view.getUint32(offset, littleEndian); // LONG + if (type === 5) { // RATIONAL + const num = view.getUint32(offset, littleEndian); + const den = view.getUint32(offset + 4, littleEndian); + return den === 0 ? 0 : num / den; + } + return null; + } + + function readIfd(view, tiffStart, ifdOffset, littleEndian) { + const entries = {}; + const count = view.getUint16(tiffStart + ifdOffset, littleEndian); + for (let i = 0; i < count; i++) { + const entryOffset = tiffStart + ifdOffset + 2 + i * 12; + const tag = view.getUint16(entryOffset, littleEndian); + const type = view.getUint16(entryOffset + 2, littleEndian); + const valueCount = view.getUint32(entryOffset + 4, littleEndian); + const size = (TYPE_SIZES[type] || 1) * valueCount; + const valueOffset = size > 4 + ? tiffStart + view.getUint32(entryOffset + 8, littleEndian) + : entryOffset + 8; + if (type === 5 && valueCount === 3) { + // GPSLatitude/Longitude: [degrees, minutes, seconds] rationals. + entries[tag] = [0, 1, 2].map(i => readValue(view, type, 2, valueOffset + i * 8, littleEndian)); + } else { + entries[tag] = readValue(view, type, valueCount, valueOffset, littleEndian); + } + } + return entries; + } + + function dmsToDecimal(dms, ref) { + if (!Array.isArray(dms) || dms.length !== 3) return null; + const [d, m, s] = dms; + let dec = d + m / 60 + s / 3600; + if (ref === 'S' || ref === 'W') dec = -dec; + return dec; + } + + function parseDateTimeOriginal(str) { + // "YYYY:MM:DD HH:MM:SS" — EXIF's own format, always local time with no + // timezone info, so this is treated as a plain calendar date. + const m = /^(\d{4}):(\d{2}):(\d{2})/.exec(str || ''); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + } + + function parse(buffer) { + const view = new DataView(buffer); + if (view.getUint16(0) !== 0xffd8) return null; // not a JPEG + + let offset = 2; + while (offset < view.byteLength - 4) { + const marker = view.getUint16(offset); + if ((marker & 0xff00) !== 0xff00) break; // not a valid marker, bail out + if (marker === 0xffd8) { offset += 2; continue; } + if (marker === 0xffda || marker === 0xffd9) break; // start of scan / EOI — no more APPn markers past this + + const segLength = view.getUint16(offset + 2); + if (marker === 0xffe1) { + const exifStart = offset + 4; + if (view.getUint32(exifStart) === 0x45786966) { // "Exif" + const tiffStart = exifStart + 6; + const littleEndian = view.getUint16(tiffStart) === 0x4949; + const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian); + const ifd0 = readIfd(view, tiffStart, ifd0Offset, littleEndian); + + let date = null; + let lat = null, lon = null; + + if (ifd0[0x8769] != null) { + const exifIfd = readIfd(view, tiffStart, ifd0[0x8769], littleEndian); + date = parseDateTimeOriginal(exifIfd[0x9003]); + } + if (ifd0[0x8825] != null) { + const gpsIfd = readIfd(view, tiffStart, ifd0[0x8825], littleEndian); + lat = dmsToDecimal(gpsIfd[0x0002], gpsIfd[0x0001]); + lon = dmsToDecimal(gpsIfd[0x0004], gpsIfd[0x0003]); + } + return { date, lat, lon }; + } + } + offset += 2 + segLength; + } + return null; + } + + return { + // Returns { date: Date|null, lat: number|null, lon: number|null }, or + // null on any parse failure — never throws. + async read(file) { + try { + const buffer = await file.arrayBuffer(); + return parse(buffer); + } catch { + return null; + } + }, + }; +})(); diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..17faa99 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/fonts/OFL.txt b/fonts/OFL.txt new file mode 100644 index 0000000..f0d8950 --- /dev/null +++ b/fonts/OFL.txt @@ -0,0 +1,6 @@ +Caveat — SIL Open Font License, Version 1.1 + +Fetched from Google Fonts (fonts.gstatic.com), latin subset, variable +weight (400-700). See https://fonts.google.com/specimen/Caveat for the +canonical source and full license text: +https://openfontlicense.org diff --git a/fonts/caveat-latin.woff2 b/fonts/caveat-latin.woff2 new file mode 100644 index 0000000..5a34234 Binary files /dev/null and b/fonts/caveat-latin.woff2 differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..ac8912b --- /dev/null +++ b/index.html @@ -0,0 +1,74 @@ + + + + + + postcard + + + + +
+
+

postcard

+
+

turn a photo into something that looks like it was mailed

+ +
+ + + +
+ + +
+ +
+ + +
+ + + + + diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..8a820bf --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1252 @@ +{ + "name": "postcard-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "postcard-server", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.3.0", + "express": "^4.19.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..cdedaa4 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/caption.js b/server/src/caption.js new file mode 100644 index 0000000..77a05a6 --- /dev/null +++ b/server/src/caption.js @@ -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) +} diff --git a/server/src/cleanup.js b/server/src/cleanup.js new file mode 100644 index 0000000..8eac252 --- /dev/null +++ b/server/src/cleanup.js @@ -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) +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..66f28f2 --- /dev/null +++ b/server/src/config.js @@ -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 ?? '', +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..f81c713 --- /dev/null +++ b/server/src/db.js @@ -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) +} diff --git a/server/src/geocode.js b/server/src/geocode.js new file mode 100644 index 0000000..ba8c3cd --- /dev/null +++ b/server/src/geocode.js @@ -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 + } +} diff --git a/server/src/ids.js b/server/src/ids.js new file mode 100644 index 0000000..d08e50f --- /dev/null +++ b/server/src/ids.js @@ -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) +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..9e2483a --- /dev/null +++ b/server/src/index.js @@ -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}`) +}) diff --git a/server/src/ratelimit.js b/server/src/ratelimit.js new file mode 100644 index 0000000..4898089 --- /dev/null +++ b/server/src/ratelimit.js @@ -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) + } +} diff --git a/server/src/routes.js b/server/src/routes.js new file mode 100644 index 0000000..360f2a0 --- /dev/null +++ b/server/src/routes.js @@ -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', '

this one\'s gone — wrong link, or it already expired.

')) + 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, '>') +} + +function renderPage(title, bodyHtml) { + return ` + + + + + postcard — ${escapeHtml(title)} + + + +
+

postcard

+
${bodyHtml}
+
+ +` +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..37d1a06 --- /dev/null +++ b/style.css @@ -0,0 +1,296 @@ +/* Self-hosted (fonts/caveat-latin.woff2, SIL OFL — see fonts/OFL.txt) so + the caption webfont doesn't depend on fonts.googleapis.com at runtime. */ +@font-face { + font-family: 'Caveat'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('fonts/caveat-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122; +} + +:root { + --bg: #0c0a08; + --bg-alt: #14100c; + --text: #cbc3b8; + --muted: #6b6258; + --heading: #f2e9dc; + --accent: #d9873f; + --accent-dim: rgba(217, 135, 63, 0.12); + --accent-border:rgba(217, 135, 63, 0.28); + --border: rgba(255, 245, 230, 0.08); + --shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6); + --error: #ff6b6b; + --error-dim: rgba(255, 107, 107, 0.14); + + --mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace; + --sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + + font: 16px/1.6 var(--sans); + color: var(--text); + background: var(--bg); + color-scheme: dark; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { margin: 0; } + +#app { + width: 640px; + max-width: 100%; + margin: 0 auto; + padding: 32px 16px 64px; +} + +h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; } +p { margin: 0; } + +/* ── Header ─────────────────────────────────────────────────────────── */ + +.header-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 8px; +} + +.header-row h1 { + font-size: 32px; + font-weight: 700; + letter-spacing: -0.5px; + text-align: center; + margin: 0; +} + +.header-row h1::before { + content: '> '; + color: var(--muted); + font-weight: 400; +} + +.hint { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); + text-align: center; + margin-bottom: 32px; +} + +/* ── Card ───────────────────────────────────────────────────────────── */ + +.card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 24px; + background: var(--bg-alt); + box-shadow: var(--shadow); + margin-bottom: 16px; +} + +/* ── Drop zone ──────────────────────────────────────────────────────── */ + +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 48px 16px; + border: 1px dashed var(--border); + border-radius: 8px; + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s; +} + +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--accent-border); + background: var(--accent-dim); +} + +.drop-zone-label { + font-family: var(--mono); + font-size: 14px; + color: var(--text); +} + +.source-row { + justify-content: center; + margin-top: 16px; +} + +.drop-zone-sub { + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +/* ── Canvas ─────────────────────────────────────────────────────────── */ + +.canvas-wrap { + display: flex; + justify-content: center; + margin-bottom: 20px; + border-radius: 8px; + overflow: hidden; + background: #000; +} + +#canvas { + max-width: 100%; + max-height: 60vh; + display: block; +} + +/* ── Controls ───────────────────────────────────────────────────────── */ + +.controls { + display: flex; + flex-direction: column; + gap: 16px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control-label { + display: flex; + justify-content: space-between; + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.control-value { + color: var(--accent); + text-transform: none; + letter-spacing: normal; +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.preset-btn { + font-family: var(--mono); + font-size: 12px; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + cursor: pointer; + transition: all 0.15s; +} + +.preset-btn:hover { border-color: var(--accent-border); } + +.preset-btn.active { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} + +/* ── Caption input ──────────────────────────────────────────────────── */ + +.caption-input { + width: 100%; + resize: vertical; + font-family: var(--sans); + font-size: 14px; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} + +.caption-input:focus { + outline: none; + border-color: var(--accent-border); +} + +/* ── Inline rows ────────────────────────────────────────────────────── */ + +.inline-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +/* ── Actions / buttons ──────────────────────────────────────────────── */ + +.actions-row { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; +} + +.btn { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + cursor: pointer; + transition: all 0.15s; + letter-spacing: 0.01em; +} + +.btn:hover { border-color: var(--accent-border); color: var(--heading); } +.btn:active { transform: scale(0.98); } +.btn:disabled { opacity: 0.5; cursor: default; transform: none; } + +.btn-small { + font-size: 11px; + padding: 6px 10px; + align-self: flex-start; +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #000; + font-weight: 700; +} + +.btn-primary:hover { background: #e59a53; border-color: #e59a53; color: #000; } + +/* ── Result panel ───────────────────────────────────────────────────── */ + +.result-panel { + margin-top: 18px; + padding: 14px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + font-family: var(--mono); + font-size: 12px; + color: var(--text); + text-align: center; +} + +.result-panel a { + color: var(--accent); + word-break: break-all; +} + +.result-panel.error { color: var(--error); border-color: var(--error-dim); }