commit 5a5c0da8a66c2c3f7768a6c50ee8b6cd9fd4068c Author: Fredrik Johansson Date: Thu Jul 16 15:19:48 2026 +0200 Initial implementation of latent: film-effects editor + develop-later server Canvas-based client-side editor (grain, vignette, halation, procedural light leaks, color-cast presets) plus a small Express/SQLite server for the two paths that need persistence: instant share links and delayed "send to develop" links with a randomized reveal time. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba18c3e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to .env to override docker-compose.yml defaults. + +# Image to deploy — set by CI/CD normally; only needed for a manual +# docker compose up. +IMAGE=repo.explewd.com/explewd/latent:latest + +# Host port to expose (container always listens on 3000). +HOST_PORT=3050 + +# Randomized "send to develop" delay range, in hours. +DEVELOP_MIN_HOURS=2 +DEVELOP_MAX_HOURS=48 + +# Retention. Instant/share-link images are a casual "just give me a link" +# flow and get a shorter TTL; delayed images must stay alive past +# develop_at with room to actually go view them, so their window is +# measured from develop_at, not upload time. +INSTANT_TTL_HOURS=24 +DELAYED_VIEW_WINDOW_HOURS=168 + +MAX_UPLOAD_BYTES=20971520 +RATE_LIMIT_PER_HOUR=30 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..093185e --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,53 @@ +name: Docker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + shell: bash + run: | + set -euo pipefail + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + SHA="${GITHUB_SHA:-${GITEA_SHA:-}}" + if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then + echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1 + fi + HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||') + IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]') + SHORT_SHA=$(echo "${SHA}" | cut -c1-7) + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.host }} + username: ${{ github.actor }} + # Create a Gitea PAT with packages:write scope and add it as a + # repository secret named TKNTKN (Settings → Secrets) + password: ${{ secrets.TKNTKN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f8d03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +data +.keep_vault diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5be1428 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +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 favicon.svg ./ + +# 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=3000 +ENV DATA_DIR=/app/data + +EXPOSE 3000 + +CMD ["node", "server/src/index.js"] diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..d1fb068 --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,135 @@ +# Proposal: `latent` — a phone-photo editor with a "develop it later" option + +**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 + +A small, fun webapp: upload a photo from your phone, apply film-style +manipulation (grain, light leaks, vignette, halation, color-cast) in the +browser, and either download it immediately or — the silly, on-brand +part — send it off to "develop," which holds it server-side behind a +randomized delay (hours to days) before the link actually resolves to the +image. Getting your photos back later, like real film, on purpose, for +no practical reason except that it's a fun mechanic. + +Named `latent` after the *latent image* — the real photographic term for +the picture that already exists on exposed film but is invisible until +developed. Both halves of this app map onto that word directly. + +Two independent finishing paths off one editor, not two separate apps: + +1. **Instant** — edit, then download directly, entirely client-side, no + server touched. Optionally generate a share link for the edited image + (explicit user action — "generate share link" — not automatic). +2. **Delayed** — edit, then "send to develop": uploads the result to + server storage, assigns a `develop_at` time chosen randomly within a + configured range, and gives back a link that won't resolve to the + actual image until that time passes. + +## Architecture + +Static/client-heavy frontend (canvas-based editing, same "no build step, +plain HTML/CSS/JS" family as `typo`/`flit`/`wisp`'s frontend) plus a small +single-process server for the two paths that need persistence: share +links and delayed development. Reuses the shape already validated by +`wisp` — blob + SQLite metadata + a background TTL-style sweep — rather +than inventing a new storage pattern. No accounts, no auth: the link +itself is the credential, same as `wisp`. + +Unlike `wisp`, this does **not** need end-to-end encryption — none of +this is sensitive, and the entire point is a link other people can open. +That actually makes the backend simpler than `wisp` despite doing more: +no client-side crypto, no key-in-fragment handling, just an opaque random +ID per stored image. + +### Editing (client-side, no server involved until "generate link" or +"send to develop" is pressed) + +All via `` — pixel manipulation, no ML, no WASM needed for a first +pass: + +- **Grain** — random noise overlay, opacity/size adjustable +- **Light leaks** — a handful of pre-baked semi-transparent PNG overlays + (warm streaks/blooms near an edge), randomized placement/rotation per + application so repeat use doesn't look identical +- **Vignette** — radial darken toward the edges +- **Halation** — bloom/glow around bright areas (blur a thresholded + bright-pixel mask, screen-blend back over the original) +- **Color cast / white balance shift** — a few presets (warm/expired-film + amber, cool/cross-processed teal, faded/low-contrast) rather than raw + sliders for every channel — keep the UI to "pick a vibe," not a full + Lightroom clone +- Adjustments should be non-destructive within a session (recompute from + the original + a param object, not chained lossy operations) so + dragging a slider back to zero actually gets you back to the original + +### Server + +Single process, SQLite for metadata (same pattern as `galr`/`stash`), +images as blobs on local disk (same pattern as `wisp`). + +**Table `images`:** + +| column | type | notes | +|---|---|---| +| `id` | text PK | random opaque ID, used in the URL | +| `blob_path` | text | where the file lives on disk | +| `mime` | text | | +| `created_at` | integer | | +| `develop_at` | integer, nullable | null = instant share (no delay gate); set = "send to develop" path | +| `expires_at` | integer | TTL sweep target — don't keep images forever regardless of path | + +**Endpoints:** + +- `POST /api/upload` — body: image bytes + `{ delayed: boolean }`. If + `delayed`, server picks `develop_at` randomly within a configured range + (e.g. env-configurable `DEVELOP_MIN_HOURS` / `DEVELOP_MAX_HOURS`) and + returns the link immediately — the whole point is you get the link + *before* it resolves, so you can send it to someone and both of you + wait. If not delayed, `develop_at` is null and the link resolves right + away (that's the "optional share link" path off instant download). +- `GET /i/:id` — serves the image if `develop_at` is null or already + passed; otherwise a small "still developing, check back in ~Nh" page + (server can compute and show a rough remaining time without revealing + the exact `develop_at`, to keep a little mystery — exact behavior is an + open question below). +- TTL sweep (interval-based, same "deliberately dumb, not push-based" + precedent as `keep watch`/`wisp`'s sweep) deletes blobs + rows past + `expires_at` regardless of whether they were ever viewed. + +No auth on any endpoint — this is a public, no-accounts toy. Rate-limit +`/api/upload` (basic IP-based throttle) to avoid it becoming an open file +host. + +## Open questions + +- **Exact delay range** — "hours to days" is the pitch; needs an actual + default (e.g. 2–48 hours?) and whether it's configurable per-upload or + fixed server-side via env. +- **What the "still developing" page shows** — exact countdown vs. vague + "come back later" vs. a tiny animated placeholder (fogged/blank photo + silhouette) for flavor. Worth spending a *little* design effort here + since it's the whole gimmick. +- **Filter presets vs. raw controls** — start with presets-only (faster + to ship, more "fun toy" than "editing tool") and add fine controls + later if it turns out sliders are actually wanted. +- **Mobile upload flow specifics** — `` for + direct camera access vs. just a normal file picker; probably both, + file picker as the fallback. +- **Image size limits / re-encoding** — phone camera photos can be large; + decide whether to downscale/re-encode on upload (bandwidth + storage) + or store as-is up to some cap. +- **Retention for the instant/non-delayed path** — does a same-length TTL + apply, or does it expire faster than the delayed path since it's a much + more casual "just give me a link" flow? + +## Non-goals + +- No accounts, no galleries, no "your uploads" history — every image + stands alone via its own link, consistent with `wisp`'s throwaway + philosophy. +- No e2e encryption — deliberately out of scope, see Architecture. +- No collaborative/multi-user editing — one uploader, one result. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae22958 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# latent + +A phone-photo editor with a "develop it later" option. Upload a photo, +apply film-style effects (grain, light leaks, vignette, halation, color +cast) entirely in the browser, then either download it right away or send +it off to "develop" — the server holds it behind a randomized delay +(hours to days) before the link actually resolves to the image. Getting +your photos back later, on purpose, for no practical reason except that +it's a fun mechanic. + +Named after the *latent image*, the photographic term for the picture +that already exists on exposed film but is invisible until developed. + +See [PROPOSAL.md](PROPOSAL.md) for the original design writeup. + +## Two finishing paths off one editor + +1. **Instant** — edit, then download directly, entirely client-side. + Optionally generate a share link (explicit action, not automatic). +2. **Delayed** — edit, then "send to develop": uploads to server storage, + assigns a random `develop_at` within a configured range, and gives back + a link immediately — it just won't resolve to the actual image until + that time passes. + +## Running locally + +``` +npm install --prefix server +npm start --prefix server +``` + +Then open http://localhost:3000. `DATA_DIR` defaults to `./data` +(gitignored) for the SQLite db and image blobs. + +## Deploying + +`docker compose up` using the provided `docker-compose.yml` / +`.env.example` (copy `.env.example` to `.env` and adjust). CI builds and +pushes the image on push to `main` via `.gitea/workflows/docker.yml`. + +## Config + +All via env vars, see `.env.example`: + +- `DEVELOP_MIN_HOURS` / `DEVELOP_MAX_HOURS` — randomized delay range for + the "send to develop" path (default 2–48h). +- `INSTANT_TTL_HOURS` — retention for share links off the instant path + (default 24h; deliberately shorter than the delayed path since it's a + more casual "just give me a link" flow). +- `DELAYED_VIEW_WINDOW_HOURS` — how long a delayed image stays available + *after* `develop_at` passes, before the TTL sweep deletes it (default + 168h / 7 days). +- `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no + accounts or auth on any endpoint, the link itself is the credential. + +## Non-goals + +No accounts, no galleries, no e2e encryption (nothing here is sensitive — +the whole point is a link other people can open), no collaborative +editing. diff --git a/app.js b/app.js new file mode 100644 index 0000000..2131e9d --- /dev/null +++ b/app.js @@ -0,0 +1,305 @@ +(() => { + const fileInput = document.getElementById('file-input'); + 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 grainSlider = document.getElementById('grain'); + const vignetteSlider = document.getElementById('vignette'); + const halationSlider = document.getElementById('halation'); + const leakSlider = document.getElementById('leak'); + const rerollLeakBtn = document.getElementById('reroll-leak'); + const presetRow = document.getElementById('preset-row'); + + const resetBtn = document.getElementById('reset-btn'); + const newPhotoBtn = document.getElementById('new-photo-btn'); + const downloadBtn = document.getElementById('download-btn'); + const shareBtn = document.getElementById('share-btn'); + const developBtn = document.getElementById('develop-btn'); + const resultPanel = document.getElementById('result-panel'); + + // Offscreen canvases reused across renders so we're not allocating per frame. + const work = document.createElement('canvas'); + const workCtx = work.getContext('2d'); + const bloom = document.createElement('canvas'); + const bloomCtx = bloom.getContext('2d'); + const noiseCanvas = document.createElement('canvas'); + const noiseCtx = noiseCanvas.getContext('2d'); + + let originalImg = null; + + const PRESETS = { + none: { filter: 'none' }, + // "pick a vibe" presets rather than raw white-balance sliders, per + // PROPOSAL.md — expressed as CSS filter strings applied at draw time. + warm: { filter: 'sepia(0.6) saturate(1.8) hue-rotate(-15deg) contrast(1.1) brightness(1.05)' }, + cool: { filter: 'saturate(1.9) hue-rotate(165deg) contrast(1.2) brightness(0.9)' }, + faded: { filter: 'sepia(0.2) saturate(0.35) contrast(0.7) brightness(1.15)' }, + }; + + const params = { + preset: 'none', + grain: 0, + vignette: 0, + halation: 0, + leak: 0, + leakSeed: Math.random(), + }; + + function loadFile(file) { + if (!file || !file.type.startsWith('image/')) return; + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + originalImg = img; + URL.revokeObjectURL(url); + const maxDim = 2400; // cap so a raw phone photo doesn't choke canvas ops + let w = img.naturalWidth; + let h = img.naturalHeight; + if (Math.max(w, h) > maxDim) { + const scale = maxDim / Math.max(w, h); + w = Math.round(w * scale); + h = Math.round(h * scale); + } + canvas.width = w; + canvas.height = h; + work.width = w; + work.height = h; + bloom.width = w; + bloom.height = h; + dropCard.hidden = true; + editorCard.hidden = false; + resultPanel.hidden = true; + resetParams(); + render(); + }; + img.src = url; + } + + function resetParams() { + params.preset = 'none'; + params.grain = 0; + params.vignette = 0; + params.halation = 0; + params.leak = 0; + params.leakSeed = Math.random(); + grainSlider.value = 0; + vignetteSlider.value = 0; + halationSlider.value = 0; + leakSlider.value = 0; + updateValueLabels(); + [...presetRow.children].forEach(b => b.classList.toggle('active', b.dataset.preset === 'none')); + } + + function updateValueLabels() { + document.getElementById('grain-value').textContent = params.grain; + document.getElementById('vignette-value').textContent = params.vignette; + document.getElementById('halation-value').textContent = params.halation; + document.getElementById('leak-value').textContent = params.leak; + } + + // Recomputes the full stack from the original image every time — never + // chains operations onto already-processed pixels — so dragging any + // slider back to 0 reproduces the source exactly. + function render() { + if (!originalImg) return; + const w = canvas.width; + const h = canvas.height; + + workCtx.filter = PRESETS[params.preset].filter; + workCtx.globalCompositeOperation = 'source-over'; + workCtx.clearRect(0, 0, w, h); + workCtx.drawImage(originalImg, 0, 0, w, h); + workCtx.filter = 'none'; + + if (params.halation > 0) applyHalation(w, h); + if (params.vignette > 0) applyVignette(w, h); + if (params.leak > 0) applyLightLeak(w, h); + if (params.grain > 0) applyGrain(w, h); + + ctx.clearRect(0, 0, w, h); + ctx.drawImage(work, 0, 0); + } + + // Bloom around bright areas: blur a thresholded bright-pass and screen it + // back over the original. + function applyHalation(w, h) { + bloomCtx.clearRect(0, 0, w, h); + bloomCtx.filter = 'brightness(180%) contrast(400%) saturate(120%)'; + bloomCtx.drawImage(work, 0, 0); + bloomCtx.filter = 'none'; + + const blurPx = Math.max(4, Math.round(Math.min(w, h) * 0.02)); + bloomCtx.filter = `blur(${blurPx}px)`; + bloomCtx.drawImage(bloom, 0, 0); + bloomCtx.filter = 'none'; + + workCtx.save(); + workCtx.globalCompositeOperation = 'screen'; + workCtx.globalAlpha = (params.halation / 100) * 0.85; + workCtx.drawImage(bloom, 0, 0); + workCtx.restore(); + } + + function applyVignette(w, h) { + const amount = params.vignette / 100; + const cx = w / 2; + const cy = h / 2; + const outerR = Math.hypot(cx, cy); + const grad = workCtx.createRadialGradient(cx, cy, outerR * 0.35, cx, cy, outerR); + grad.addColorStop(0, 'rgba(0,0,0,0)'); + grad.addColorStop(1, `rgba(0,0,0,${amount * 0.85})`); + workCtx.save(); + workCtx.globalCompositeOperation = 'multiply'; + workCtx.fillStyle = grad; + workCtx.fillRect(0, 0, w, h); + workCtx.restore(); + } + + // Warm streak/bloom near a randomized edge point, rotated per seed so + // repeat use doesn't look identical — no pre-baked PNG assets needed, + // it's a procedural gradient. + function applyLightLeak(w, h) { + const seed = params.leakSeed; + const angle = seed * Math.PI * 2; + const edgeX = w / 2 + Math.cos(angle) * w * 0.7; + const edgeY = h / 2 + Math.sin(angle) * h * 0.7; + const radius = Math.max(w, h) * (0.5 + seed * 0.3); + + const grad = workCtx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, radius); + const hue = 25 + seed * 30; // warm amber/red range + grad.addColorStop(0, `hsla(${hue}, 90%, 60%, ${(params.leak / 100) * 0.9})`); + grad.addColorStop(0.5, `hsla(${hue + 10}, 90%, 55%, ${(params.leak / 100) * 0.35})`); + grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)'); + + workCtx.save(); + workCtx.globalCompositeOperation = 'screen'; + workCtx.fillStyle = grad; + workCtx.fillRect(0, 0, w, h); + workCtx.restore(); + } + + function applyGrain(w, h) { + // Noise is generated at reduced resolution and scaled up — a + // per-pixel random overlay at full phone-photo resolution is + // needlessly slow and reads no differently once blended in. + const nw = Math.max(1, Math.round(w / 3)); + const nh = Math.max(1, Math.round(h / 3)); + noiseCanvas.width = nw; + noiseCanvas.height = nh; + const imgData = noiseCtx.createImageData(nw, nh); + const d = imgData.data; + for (let i = 0; i < d.length; i += 4) { + const v = Math.random() * 255; + d[i] = v; + d[i + 1] = v; + d[i + 2] = v; + d[i + 3] = 255; + } + noiseCtx.putImageData(imgData, 0, 0); + + workCtx.save(); + workCtx.globalCompositeOperation = 'overlay'; + workCtx.globalAlpha = (params.grain / 100) * 0.6; + workCtx.imageSmoothingEnabled = false; + workCtx.drawImage(noiseCanvas, 0, 0, w, h); + workCtx.restore(); + } + + 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(delayed) { + const btn = delayed ? developBtn : shareBtn; + btn.disabled = true; + showResult('uploading…'); + try { + const blob = await canvasToBlob(); + const res = await fetch(`/api/upload?delayed=${delayed}`, { + 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}`; + if (delayed) { + showResult( + `sent to develop. link: ${link}
` + + `ready sometime in the next ${data.developInHours} hours — ` + + `same link resolves once it's done.` + ); + } else { + showResult(`share link: ${link}`); + } + } catch (err) { + showResult(err.message || 'something went wrong', true); + } finally { + btn.disabled = false; + } + } + + fileInput.addEventListener('change', () => loadFile(fileInput.files[0])); + + ['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]); + }); + + [...presetRow.children].forEach(btn => { + btn.addEventListener('click', () => { + params.preset = btn.dataset.preset; + [...presetRow.children].forEach(b => b.classList.toggle('active', b === btn)); + render(); + }); + }); + + grainSlider.addEventListener('input', () => { params.grain = Number(grainSlider.value); updateValueLabels(); render(); }); + vignetteSlider.addEventListener('input', () => { params.vignette = Number(vignetteSlider.value); updateValueLabels(); render(); }); + halationSlider.addEventListener('input', () => { params.halation = Number(halationSlider.value); updateValueLabels(); render(); }); + leakSlider.addEventListener('input', () => { params.leak = Number(leakSlider.value); updateValueLabels(); render(); }); + rerollLeakBtn.addEventListener('click', () => { params.leakSeed = Math.random(); render(); }); + + resetBtn.addEventListener('click', () => { resetParams(); render(); }); + newPhotoBtn.addEventListener('click', () => { + originalImg = null; + fileInput.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 = `latent-${Date.now()}.jpg`; + a.click(); + URL.revokeObjectURL(url); + }); + + shareBtn.addEventListener('click', () => upload(false)); + developBtn.addEventListener('click', () => upload(true)); +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b935a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + app: + image: ${IMAGE} + container_name: latent + restart: unless-stopped + pull_policy: always + ports: + - "${HOST_PORT:-3050}:3090" + environment: + - DEVELOP_MIN_HOURS=${DEVELOP_MIN_HOURS:-2} + - DEVELOP_MAX_HOURS=${DEVELOP_MAX_HOURS:-48} + - INSTANT_TTL_HOURS=${INSTANT_TTL_HOURS:-24} + - DELAYED_VIEW_WINDOW_HOURS=${DELAYED_VIEW_WINDOW_HOURS:-168} + - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-20971520} + - RATE_LIMIT_PER_HOUR=${RATE_LIMIT_PER_HOUR:-30} + volumes: + # Named volume, not a bind mount into the build context — the SQLite + # db and image blobs live here and must survive image redeploys. + - latent-data:/app/data + +volumes: + latent-data: diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..dcb4255 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..2405cee --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + latent + + + + +
+
+

latent

+
+

film-style edits, in the browser — develop instantly, or wait

+ +
+ + +
+ + +
+ + + + diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..bb260cb --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1252 @@ +{ + "name": "latent-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "latent-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..5efeda2 --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "latent-server", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Small server for latent's two persistence paths: share links and delayed development", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "better-sqlite3": "^11.3.0", + "express": "^4.19.2" + } +} diff --git a/server/src/cleanup.js b/server/src/cleanup.js new file mode 100644 index 0000000..f46b2da --- /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, per +// PROPOSAL.md's "deliberately dumb" precedent from wisp/keep-watch. +export function sweepExpiredImages() { + const now = Math.floor(Date.now() / 1000) + const expired = db.prepare(`SELECT id FROM images WHERE expires_at < ?`).all(now) + + for (const { id } of expired) { + fs.rm(blobPath(id), { force: true }, () => {}) + } + if (expired.length > 0) { + db.prepare(`DELETE FROM images WHERE expires_at < ?`).run(now) + } + + sweepRateLimitWindows() +} + +export function startCleanupJob() { + return setInterval(sweepExpiredImages, config.cleanupIntervalSeconds * 1000) +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..e9ce35e --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,24 @@ +import path from 'node:path' + +export const config = { + port: Number(process.env.PORT ?? 3000), + dataDir: process.env.DATA_DIR ?? path.resolve('data'), + + // "Hours to days" per PROPOSAL.md — server picks a develop_at uniformly + // at random in this range on every delayed upload. + developMinHours: Number(process.env.DEVELOP_MIN_HOURS ?? 2), + developMaxHours: Number(process.env.DEVELOP_MAX_HOURS ?? 48), + + // Retention: the instant/share-link path is a casual "just give me a + // link" flow, so it gets a much shorter TTL than the delayed path. + // Delayed images must stay alive past develop_at with room to actually + // go view them, so their TTL is measured from develop_at, not upload time. + instantTtlHours: Number(process.env.INSTANT_TTL_HOURS ?? 24), + delayedViewWindowHours: Number(process.env.DELAYED_VIEW_WINDOW_HOURS ?? 168), // 7 days after develop_at + + 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), +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..7cb3161 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,25 @@ +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, 'latent.db')) +db.pragma('journal_mode = WAL') + +db.exec(` + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + blob_path TEXT NOT NULL, + mime TEXT NOT NULL, + created_at INTEGER NOT NULL, + develop_at INTEGER, + expires_at INTEGER NOT NULL + ); +`) + +export function blobPath(id) { + return path.join(config.dataDir, 'blobs', id) +} diff --git a/server/src/ids.js b/server/src/ids.js new file mode 100644 index 0000000..cd07e08 --- /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 newImageId() { + return randomBase62(128) +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..b6e1ae4 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,29 @@ +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 { sweepExpiredImages, startCleanupJob } from './cleanup.js' + +const app = express() + +// Deployed behind a reverse proxy in production (same as the other +// single-process toys in this family) — 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)) + +sweepExpiredImages() +startCleanupJob() + +app.listen(config.port, () => { + console.log(`latent server listening on :${config.port}`) +}) diff --git a/server/src/ratelimit.js b/server/src/ratelimit.js new file mode 100644 index 0000000..fb59a5d --- /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, per PROPOSAL.md. +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..5ab9ca8 --- /dev/null +++ b/server/src/routes.js @@ -0,0 +1,150 @@ +import { Router } from 'express' +import fs from 'node:fs' +import { config } from './config.js' +import { db, blobPath } from './db.js' +import { newImageId } from './ids.js' +import { checkRateLimit } from './ratelimit.js' + +export const apiRouter = Router() +export const imageRouter = Router() + +const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']) + +function randomInt(min, max) { + return Math.floor(min + Math.random() * (max - min)) +} + +function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>') +} + +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 delayed = req.query.delayed === 'true' + const id = newImageId() + 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) + let developAt = null + let expiresAt + + if (delayed) { + const hours = randomInt(config.developMinHours, config.developMaxHours) + developAt = now + hours * 3600 + expiresAt = developAt + config.delayedViewWindowHours * 3600 + } else { + expiresAt = now + config.instantTtlHours * 3600 + } + + db.prepare( + `INSERT INTO images (id, blob_path, mime, created_at, develop_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, dest, mime, now, developAt, expiresAt) + + res.json({ + id, + url: `/i/${id}`, + developInHours: delayed ? Math.ceil((developAt - now) / 3600) : null, + }) + }) +}) + +imageRouter.get('/i/:id', (req, res) => { + const image = db.prepare(`SELECT * FROM images WHERE id = ?`).get(req.params.id) + const now = Math.floor(Date.now() / 1000) + + if (!image || image.expires_at < now || !fs.existsSync(image.blob_path)) { + res.status(404).send(renderPage('not found', '

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

')) + return + } + + if (image.develop_at && image.develop_at > now) { + const remainingHours = Math.ceil((image.develop_at - now) / 3600) + res.status(200).send(renderPage('still developing', developingBody(remainingHours))) + return + } + + res.setHeader('Content-Type', image.mime) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + fs.createReadStream(image.blob_path).pipe(res) +}) + +// Rounded to a vague bucket rather than an exact countdown — a little +// mystery is the point, per PROPOSAL.md's open question on this page. +function developingBody(remainingHours) { + let phrase + if (remainingHours <= 1) phrase = 'any time now' + else if (remainingHours <= 6) phrase = 'in a few hours' + else if (remainingHours <= 24) phrase = 'later today or tomorrow' + else if (remainingHours <= 72) phrase = 'in a couple of days' + else phrase = 'in a few days' + + return ` +
+
+

still developing — check back ${escapeHtml(phrase)}.

+

same link, it'll just work once it's ready.

+
+ ` +} + +function renderPage(title, bodyHtml) { + return ` + + + + + latent — ${escapeHtml(title)} + + + +
+

latent

+
${bodyHtml}
+
+ +` +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..3426fc6 --- /dev/null +++ b/style.css @@ -0,0 +1,317 @@ +:root { + --bg: #0c0a08; + --bg-alt: #14100c; + --text: #cbc3b8; + --muted: #6b6258; + --heading: #f2e9dc; + --accent: #e8a33d; + --accent-dim: rgba(232, 163, 61, 0.12); + --accent-border:rgba(232, 163, 61, 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); +} + +.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); +} + +.slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + border-radius: 2px; + background: var(--border); + outline: none; +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-alt); +} + +.slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-alt); +} + +.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); +} + +/* ── 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: #f0b25a; border-color: #f0b25a; color: #000; } + +.btn-develop { + border-color: rgba(120, 140, 255, 0.3); + color: #b7c2ff; +} + +.btn-develop:hover { border-color: rgba(120, 140, 255, 0.6); color: #d6dcff; } + +/* ── 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); } + +/* ── Developing page (server-rendered) ─────────────────────────────── */ + +.develop-wrap { + text-align: center; + padding: 64px 16px; +} + +.fog { + width: 220px; + height: 220px; + margin: 0 auto 24px; + border-radius: 8px; + background: linear-gradient(135deg, #1a1712, #0c0a08); + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} + +.fog::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 50% 50%, rgba(232,163,61,0.08), transparent 70%); + animation: drift 6s ease-in-out infinite; +} + +@keyframes drift { + 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; } + 50% { transform: scale(1.15) translate(4%, -4%); opacity: 1; } +}