From cf0f5fca2cb173f79b5a7166dcff18c6e6b6e280 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Thu, 6 Aug 2026 19:49:03 +0200 Subject: [PATCH] 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 --- .env.example | 23 + .gitignore | 5 + Dockerfile | 26 + PROPOSAL.md | 143 +++++ README.md | 84 +++ app.js | 376 ++++++++++++ docker-compose.yml | 25 + exif.js | 117 ++++ favicon.svg | 4 + fonts/OFL.txt | 6 + fonts/caveat-latin.woff2 | Bin 0 -> 74932 bytes index.html | 74 +++ server/package-lock.json | 1252 ++++++++++++++++++++++++++++++++++++++ server/package.json | 15 + server/src/caption.js | 55 ++ server/src/cleanup.js | 25 + server/src/config.js | 22 + server/src/db.js | 24 + server/src/geocode.js | 55 ++ server/src/ids.js | 23 + server/src/index.js | 28 + server/src/ratelimit.js | 28 + server/src/routes.js | 133 ++++ style.css | 296 +++++++++ 24 files changed, 2839 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PROPOSAL.md create mode 100644 README.md create mode 100644 app.js create mode 100644 docker-compose.yml create mode 100644 exif.js create mode 100644 favicon.svg create mode 100644 fonts/OFL.txt create mode 100644 fonts/caveat-latin.woff2 create mode 100644 index.html create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/caption.js create mode 100644 server/src/cleanup.js create mode 100644 server/src/config.js create mode 100644 server/src/db.js create mode 100644 server/src/geocode.js create mode 100644 server/src/ids.js create mode 100644 server/src/index.js create mode 100644 server/src/ratelimit.js create mode 100644 server/src/routes.js create mode 100644 style.css 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 0000000000000000000000000000000000000000..5a3423426fccb5d10e0a1cb1ba523e33b95c51bf GIT binary patch literal 74932 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 literal 0 HcmV?d00001 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); }