From 38c3df818768564ce61bd2398e269ef00d42c873 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 24 May 2026 20:53:22 +0200 Subject: [PATCH] feat(images): responsive , gated private images + prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build-time image optimization plus auth-gated private content. - (src/lib/components/Image.svelte): wraps @sveltejs/enhanced-img for public images under src/lib/assets/images/ (AVIF/WebP, multiple widths, lazy by default), plus a `private` mode for auth-gated images. - Private images: scripts/build-private-images.ts encodes sources from src/lib/assets/private-images/ into private-assets/ (outside the bundle) and a manifest; served only via the auth-checked /private-images/ endpoint (X-Accel-Redirect in prod, disk read in dev). - HikeImage gains a `src` prose mode: build-hikes encodes non-waypoint images referenced in .svx and exposes them by filename (imagesByName); a `private` attr routes them through the gated /hikes//private/ path. - (src/lib/components/Private.svelte): renders prose only to logged-in viewers (cosmetic gating — text still ships in the bundle). - deploy.sh rsyncs private-assets/; prod needs an nginx internal /protected-images/ location. --- .gitignore | 6 + package.json | 5 +- pnpm-lock.yaml | 50 +++++ scripts/build-hikes.ts | 77 +++++-- scripts/build-private-images.ts | 198 ++++++++++++++++++ scripts/deploy.sh | 17 +- src/lib/assets/images/README.md | 37 ++++ src/lib/assets/private-images/README.md | 45 ++++ src/lib/components/Image.svelte | 192 +++++++++++++++++ src/lib/components/Private.svelte | 71 +++++++ src/lib/components/hikes/HikeImage.svelte | 77 ++++++- .../components/hikes/hikeContext.svelte.ts | 8 +- src/routes/hikes/[slug]/+page.svelte | 4 +- .../private-images/[...file]/+server.ts | 75 +++++++ src/types/hikes.ts | 13 ++ src/types/images.ts | 18 ++ vite.config.ts | 7 +- 17 files changed, 870 insertions(+), 30 deletions(-) create mode 100644 scripts/build-private-images.ts create mode 100644 src/lib/assets/images/README.md create mode 100644 src/lib/assets/private-images/README.md create mode 100644 src/lib/components/Image.svelte create mode 100644 src/lib/components/Private.svelte create mode 100644 src/routes/private-images/[...file]/+server.ts create mode 100644 src/types/images.ts diff --git a/.gitignore b/.gitignore index 28659291..2a43c834 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,12 @@ static/shopping/cumulus.svg static/hikes/ hikes-assets/ src/lib/data/hikes.generated.ts +# Private image build outputs (regenerated by scripts/build-private-images.ts). +# Sources are private + large, so they're ignored too — only the README is kept. +private-assets/ +src/lib/data/privateImages.generated.ts +src/lib/assets/private-images/* +!src/lib/assets/private-images/README.md # Build-script disk caches (Swisstopo identify, BRouter responses, ...) scripts/.cache/ # Loose working-tree scratch files (notes, photos, prototypes) that aren't diff --git a/package.json b/package.json index eca6ee40..47329366 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "homepage", - "version": "1.86.2", + "version": "1.87.0", "private": true, "type": "module", "scripts": { "dev": "vite dev", - "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts", + "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts", "build": "vite build", "postbuild": "pnpm exec vite-node scripts/build-error-page.ts", "preview": "vite preview", @@ -31,6 +31,7 @@ "devDependencies": { "@playwright/test": "1.56.1", "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/enhanced-img": "^0.10.4", "@sveltejs/kit": "^2.56.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tauri-apps/cli": "^2.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a93c92e6..a96900b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@sveltejs/adapter-auto': specifier: ^7.0.1 version: 7.0.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))) + '@sveltejs/enhanced-img': + specifier: ^0.10.4 + version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)) '@sveltejs/kit': specifier: ^2.56.1 version: 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)) @@ -925,6 +928,13 @@ packages: peerDependencies: '@sveltejs/kit': ^2.4.0 + '@sveltejs/enhanced-img@0.10.4': + resolution: {integrity: sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0 || ^7.0.0 + svelte: ^5.0.0 + vite: ^6.3.0 || >=7.0.0 + '@sveltejs/kit@2.56.1': resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==} engines: {node: '>=18.13'} @@ -1448,6 +1458,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + imagetools-core@9.1.0: + resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==} + engines: {node: '>=20.0.0'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1914,6 +1928,11 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-parse-markup@0.1.5: + resolution: {integrity: sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==} + peerDependencies: + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1 + svelte@5.55.1: resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==} engines: {node: '>=18'} @@ -2010,6 +2029,10 @@ packages: vfile-message@2.0.4: resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + vite-imagetools@9.0.3: + resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==} + engines: {node: '>=20.0.0'} + vite-node@6.0.0: resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2744,6 +2767,19 @@ snapshots: '@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)) rollup: 4.60.1 + '@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)) + magic-string: 0.30.21 + sharp: 0.34.5 + svelte: 5.55.1 + svelte-parse-markup: 0.1.5(svelte@5.55.1) + vite: 8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0) + vite-imagetools: 9.0.3(rollup@4.60.1) + zimmerframe: 1.1.2 + transitivePeerDependencies: + - rollup + '@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))': dependencies: '@standard-schema/spec': 1.0.0 @@ -3238,6 +3274,8 @@ snapshots: ieee754@1.2.1: {} + imagetools-core@9.1.0: {} + indent-string@4.0.0: {} ip@2.0.1: @@ -3734,6 +3772,10 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-parse-markup@0.1.5(svelte@5.55.1): + dependencies: + svelte: 5.55.1 + svelte@5.55.1: dependencies: '@jridgewell/remapping': 2.3.5 @@ -3838,6 +3880,14 @@ snapshots: '@types/unist': 2.0.11 unist-util-stringify-position: 2.0.3 + vite-imagetools@9.0.3(rollup@4.60.1): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + imagetools-core: 9.1.0 + sharp: 0.34.5 + transitivePeerDependencies: + - rollup + vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0): dependencies: cac: 7.0.0 diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index d2550f7d..3ad780a0 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -38,7 +38,8 @@ import type { HikeStage, HikesOverview, ImagePoint, - ImageVariant + ImageVariant, + NamedHikeImage } from '../src/types/hikes.js'; // --------------------------------------------------------------------------- @@ -399,7 +400,9 @@ async function processImage( srcPath: string, slug: string, alt: string, - gpxImageRefs: Record + gpxImageRefs: Record, + /** Visibility for a non-waypoint prose image, or null when it isn't one. */ + forceVisibility: 'public' | 'private' | null ): Promise< | { variant: ImageVariant; thumbnailRelUrl: string; largestRelUrl: string; hash: string; visibility: 'public' | 'private'; cached: boolean; outNames: string[] } | { skipped: true; hash: string } @@ -407,14 +410,19 @@ async function processImage( const buffer = await fs.readFile(srcPath); const hash = shortHashOfBuffer(buffer); const ref = gpxImageRefs[hash]; - if (!ref) { - // Not referenced by any waypoint in track.gpx — drop it entirely (no - // encode, no manifest entry, no static output). Authors who want an - // image published must place it on the route via the route-builder - // (which writes a `` waypoint into track.gpx). + if (!ref && forceVisibility === null) { + // Not a track.gpx waypoint and not referenced in the prose — drop it + // entirely (no encode, no manifest entry, no static output). Authors + // publish an image either by placing it on the route via the route-builder + // (writes a `` waypoint) or by referencing its filename + // inline with ``. return { skipped: true, hash }; } - const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public'; + // Waypoints carry their own visibility; a prose image takes the visibility + // requested by its `` tag (public unless marked `private`). + const visibility: 'public' | 'private' = ref + ? ref.visibility === 'private' ? 'private' : 'public' + : (forceVisibility ?? 'public'); // Public images go under `images/` (served directly by nginx); private ones // under `private/` (proxied through Node for the auth check, then handed off // via X-Accel-Redirect). The encode itself is shared with the cover image. @@ -1167,11 +1175,29 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise` in the prose, keyed by + // source basename → requested visibility. These are encoded and exposed via + // `imagesByName` even when they aren't track.gpx waypoints; a `private` + // attribute on the tag routes the image into the gated `private/` segment. + // Everything else that isn't a waypoint is still dropped. + const proseImages = new Map(); + for (const m of svxSource.matchAll(/]*?\/?>/g)) { + const tag = m[0]; + const srcMatch = tag.match(/\bsrc\s*=\s*["']([^"']+)["']/); + if (!srcMatch) continue; // idx-mode tag, no filename + const name = srcMatch[1].split('/').pop(); + if (!name) continue; + // `private` as a boolean attr (`private` or `private={true}`), excluding + // the src value so a "private" substring in a filename doesn't count. + const isPrivate = /\bprivate\b/.test(tag.replace(srcMatch[0], '')); + proseImages.set(name, isPrivate ? 'private' : 'public'); + } + + // Non-waypoint, non-prose images are dropped before encoding (see + // processImage). Count for the log line below. if (imageFiles.length > 0) { console.log( - `[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} referenced in track.gpx (concurrency=${IMAGE_CONCURRENCY})…` + `[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} on route, ${proseImages.size} named in prose (concurrency=${IMAGE_CONCURRENCY})…` ); } @@ -1186,6 +1212,7 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise { const imgT0 = Date.now(); + const name = path.basename(imgPath); // Hero alt only applies to the first image; later ones get a generic // label (image basenames usually encode date/camera info that we don't // want to leak into alt text or hover tooltips). const alt = i === 0 && typeof fm.heroAlt === 'string' ? fm.heroAlt : `Bild ${i + 1}`; - const processed = await processImage(imgPath, slug, alt, gpxImageRefs); + // Encode if it's a route waypoint OR named in the prose (with the + // visibility that tag requested). + const processed = await processImage(imgPath, slug, alt, gpxImageRefs, proseImages.get(name) ?? null); if ('skipped' in processed) { console.log( - `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · skipped (not in track.gpx)` + `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${name} · ${processed.hash} · skipped (not on route, not in prose)` ); - return { variant: null, point: null, outNames: [], visibility: 'public' as const }; + return { name, variant: null, point: null, outNames: [], visibility: 'public' as const }; } - const point = extractImagePoint(processed, alt, gpxImageRefs[processed.hash]); + // Only waypoint images get a map ImagePoint; prose-only ones have no + // position, so they're exposed by name (imagesByName) instead. + const ref = gpxImageRefs[processed.hash]; + const point = ref ? extractImagePoint(processed, alt, ref) : null; const cacheTag = processed.cached ? ' · cached' : ''; + const kind = ref ? processed.visibility : 'prose'; console.log( - `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · ${processed.visibility}${cacheTag} (${Date.now() - imgT0}ms)` + `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${name} · ${processed.hash} · ${kind}${cacheTag} (${Date.now() - imgT0}ms)` ); return { + name, variant: processed.variant, point, outNames: processed.outNames, @@ -1224,6 +1259,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise`. Only the + // prose-referenced ones — keyed by basename, carrying the full srcset. + const imagesByName: Record = {}; + for (const r of results) { if (r.variant !== null) { // Fallback cover when there's no explicit `cover.*`: the first PUBLIC @@ -1233,6 +1272,9 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise 0 ? { imagesByName } : {}) }; console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`); diff --git a/scripts/build-private-images.ts b/scripts/build-private-images.ts new file mode 100644 index 00000000..d1f8dcdb --- /dev/null +++ b/scripts/build-private-images.ts @@ -0,0 +1,198 @@ +/** + * Build script for private (auth-gated) images rendered via ``. + * + * Public images use @sveltejs/enhanced-img, which emits PUBLIC hashed assets + * into the client bundle — fine for anything anyone may see. Private images + * must not be publicly reachable, so they can't go through enhanced-img. This + * script mirrors the hikes private pipeline instead: + * + * 1. Scan `src/lib/assets/private-images/` (recursively) for raster sources. + * 2. Encode each into AVIF + WebP at multiple widths with sharp, named by + * content hash, into `private-assets/` — a tree OUTSIDE the client bundle + * and outside `/static`, so SvelteKit/Vite never serve it directly. + * 3. Emit `src/lib/data/privateImages.generated.ts`: a manifest mapping each + * source path to its responsive variant, with URLs under `/private-images/` + * (the auth-gated endpoint at src/routes/private-images/[...file]/+server.ts). + * + * Deploy rsyncs `private-assets/` to the server, where nginx serves it only via + * an `internal` location (`/protected-images/`) reachable through X-Accel-Redirect + * from the endpoint — never publicly. In dev the endpoint streams from disk. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import sharp from 'sharp'; +import type { PrivateImageVariant } from '../src/types/images.js'; + +const ROOT = path.resolve(process.cwd()); +const SRC_DIR = path.join(ROOT, 'src', 'lib', 'assets', 'private-images'); +// Encoded output. Sibling of `hikes-assets/` and, like it, gitignored + rsynced +// to the server by scripts/deploy.sh (never bundled, never under /static). +const OUT_DIR = path.join(ROOT, 'private-assets'); +const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'privateImages.generated.ts'); + +// Same responsive ladder + qualities as the hikes encoder, for consistency. +const IMAGE_WIDTHS = [480, 960, 1600] as const; +const AVIF_QUALITY = 55; +const WEBP_QUALITY = 82; +const RASTER_RE = /\.(jpe?g|png|webp|avif|tiff?|gif|heic|heif)$/i; +// Sharp releases the JS thread while libvips runs, so a small pool ~linearly +// speeds up encoding. Cap at 4 to avoid thrashing smaller boxes. +const CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4)); + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function runWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length); + let next = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const i = next++; + if (i >= items.length) return; + results[i] = await worker(items[i], i); + } + }); + await Promise.all(runners); + return results; +} + +async function walk(dir: string): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + let out: string[] = []; + for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out = out.concat(await walk(full)); + else if (RASTER_RE.test(e.name)) out.push(full); + } + return out; +} + +async function encode( + srcPath: string +): Promise<{ key: string; variant: PrivateImageVariant; outNames: string[] }> { + const buffer = await fs.readFile(srcPath); + // Content hash names the output files: an existing file is byte-identical, so + // re-encodes are skipped and stale ones get swept. The source basename is + // dropped so original filenames don't leak into the (guessable) URLs. + const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8); + + const meta = await sharp(buffer).metadata(); + const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]; + const intrinsicH = meta.height ?? 0; + + let widths = IMAGE_WIDTHS.filter((w) => w <= intrinsicW); + if (widths.length === 0) widths = [intrinsicW]; + + await fs.mkdir(OUT_DIR, { recursive: true }); + + type Job = { w: number; fmt: 'avif' | 'webp'; file: string; quality: number }; + const jobs: Job[] = []; + const avif: string[] = []; + const webp: string[] = []; + const outNames: string[] = []; + let largestWebp = ''; + + for (const w of widths) { + const avifName = `${hash}.${w}.avif`; + const webpName = `${hash}.${w}.webp`; + jobs.push({ w, fmt: 'avif', file: path.join(OUT_DIR, avifName), quality: AVIF_QUALITY }); + jobs.push({ w, fmt: 'webp', file: path.join(OUT_DIR, webpName), quality: WEBP_QUALITY }); + avif.push(`/private-images/${avifName} ${w}w`); + webp.push(`/private-images/${webpName} ${w}w`); + largestWebp = `/private-images/${webpName}`; + outNames.push(avifName, webpName); + } + + const presence = await Promise.all(jobs.map((j) => pathExists(j.file))); + const pending = jobs.filter((_, i) => !presence[i]); + await Promise.all( + pending.map(async (j) => { + const pipeline = sharp(buffer).rotate().resize({ width: j.w, withoutEnlargement: true }); + if (j.fmt === 'avif') await pipeline.avif({ quality: j.quality }).toFile(j.file); + else await pipeline.webp({ quality: j.quality }).toFile(j.file); + }) + ); + + const largestW = widths[widths.length - 1]; + const scale = largestW / intrinsicW; + const height = Math.round((intrinsicH || largestW) * scale); + // Manifest key: source path relative to SRC_DIR, forward-slashed, so a caller + // writes . + const key = path.relative(SRC_DIR, srcPath).split(path.sep).join('/'); + + return { + key, + variant: { + src: largestWebp, + srcsetAvif: avif.join(', '), + srcsetWebp: webp.join(', '), + width: largestW, + height + }, + outNames + }; +} + +async function main() { + const files = await walk(SRC_DIR); + if (files.length > 0) { + console.log(`[build-private-images] encoding ${files.length} image(s) (concurrency=${CONCURRENCY})…`); + } + + const results = await runWithConcurrency(files, CONCURRENCY, (f) => encode(f)); + + const manifest: Record = {}; + const keep = new Set(); + for (const r of results) { + manifest[r.key] = r.variant; + for (const n of r.outNames) keep.add(n); + } + + // Sweep encodes from prior builds whose source was removed or changed. + if (await pathExists(OUT_DIR)) { + const existing = await fs.readdir(OUT_DIR); + const orphans = existing.filter((f) => !keep.has(f)); + if (orphans.length > 0) { + await Promise.all(orphans.map((f) => fs.unlink(path.join(OUT_DIR, f)).catch(() => {}))); + console.log(`[build-private-images] removed ${orphans.length} orphaned file(s)`); + } + } + + await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true }); + const banner = + '// AUTO-GENERATED by scripts/build-private-images.ts — do not edit by hand.\n' + + "import type { PrivateImageVariant } from '$types/images';\n\n"; + const body = `export const PRIVATE_IMAGES: Record = ${JSON.stringify( + manifest, + null, + 2 + )};\n`; + await fs.writeFile(MANIFEST_OUT, banner + body); + + console.log( + `[build-private-images] wrote ${Object.keys(manifest).length} entry(ies) to ${path.relative(ROOT, MANIFEST_OUT)}` + ); +} + +main().catch((err) => { + console.error('[build-private-images] Fatal:', err); + process.exit(1); +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 15cc9756..4cd9ed12 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -23,6 +23,12 @@ ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}" # rsync that tree to the path nginx serves from. HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}" HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}" +# Private (auth-gated) images for . Built into ./private-assets/ +# and served by nginx ONLY via an `internal` location reached through the +# endpoint's X-Accel-Redirect — add this once to the server's nginx config: +# location /protected-images/ { internal; alias /var/www/static/private-images/; } +PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}" +PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}" DRY="" if [[ "${1:-}" == "--dry-run" ]]; then @@ -89,13 +95,22 @@ else echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync" fi +if [[ -d private-assets ]]; then + echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/" + ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR" + rsync -az --delete $DRY --info=progress2 \ + private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/" +else + echo ":: No private-assets/ dir — skipping auth-gated image sync" +fi + if [[ -n "$DRY" ]]; then echo ":: Dry run complete — no service restart" exit 0 fi echo ":: Fixing ownership on server" -ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi" +ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi" echo ":: Restarting $SERVICE" ssh "$REMOTE" "systemctl restart $SERVICE" diff --git a/src/lib/assets/images/README.md b/src/lib/assets/images/README.md new file mode 100644 index 00000000..f56b52e9 --- /dev/null +++ b/src/lib/assets/images/README.md @@ -0,0 +1,37 @@ +# Public responsive image assets + +Drop public source images here, then render them with `$lib/components/Image.svelte`. + +At build time `@sveltejs/enhanced-img` (vite-imagetools + sharp) processes every +raster image in this folder into AVIF/WebP at multiple widths and strips EXIF. +Output is a public, hashed, immutable build asset. + +```svelte + + + +… + + +… + + +… + + +… +``` + +For **private, auth-gated** images use `` and put the +source in `../private-images/` instead — see that folder's README. + +Notes: + +- Provide images at ~2× the displayed size so HiDPI screens stay sharp; + processing only ever scales **down**. +- SVGs are not processed here — import them directly instead. +- First build is slow (encoding); results are cached in + `node_modules/.cache/imagetools`. +- These sources are committed (they're public site assets). diff --git a/src/lib/assets/private-images/README.md b/src/lib/assets/private-images/README.md new file mode 100644 index 00000000..3432e534 --- /dev/null +++ b/src/lib/assets/private-images/README.md @@ -0,0 +1,45 @@ +# Private (auth-gated) image sources + +Drop **private** source images here, then render them with +`` from `$lib/components/Image.svelte`. + +These can't use `@sveltejs/enhanced-img` — its output is a public asset. Instead +`scripts/build-private-images.ts` (runs at `prebuild`) encodes each image into +AVIF/WebP at multiple widths into `private-assets/` (gitignored, outside the +client bundle) and writes `src/lib/data/privateImages.generated.ts`. The bytes +are served only through the auth-gated endpoint +`src/routes/private-images/[...file]/+server.ts`. + +```svelte + + + +… + + +{#if data.session} + … +{/if} +``` + +Setup / notes: + +- **Dev:** run `pnpm exec vite-node scripts/build-private-images.ts` once (and + after adding/changing images) so the manifest + `private-assets/` exist. You + must be logged in for the gated endpoint to serve the bytes. +- **Prod (one-time):** add an nginx `internal` location so the bytes are only + reachable via the endpoint's `X-Accel-Redirect`: + + ```nginx + location /protected-images/ { + internal; + alias /var/www/static/private-images/; + } + ``` + + `scripts/deploy.sh` rsyncs `private-assets/` → `/var/www/static/private-images/`. +- These source images are **gitignored** (private + large). Back them up + separately. +- SVGs are not processed here. diff --git a/src/lib/components/Image.svelte b/src/lib/components/Image.svelte new file mode 100644 index 00000000..133c7368 --- /dev/null +++ b/src/lib/components/Image.svelte @@ -0,0 +1,192 @@ + + + + +{#if isPrivate} + {#if variant} + + + + + (locked = true)} + {...rest} + /> + + + + {#if locked} + + + {/if} + + {/if} +{:else if picture} + +{/if} + + diff --git a/src/lib/components/Private.svelte b/src/lib/components/Private.svelte new file mode 100644 index 00000000..d5a1ebe0 --- /dev/null +++ b/src/lib/components/Private.svelte @@ -0,0 +1,71 @@ + + +{#if canSee} +
+ {#if badge} + + + {/if} + {@render children()} +
+{/if} + + diff --git a/src/lib/components/hikes/HikeImage.svelte b/src/lib/components/hikes/HikeImage.svelte index 28b48a2a..c988b405 100644 --- a/src/lib/components/hikes/HikeImage.svelte +++ b/src/lib/components/hikes/HikeImage.svelte @@ -2,24 +2,57 @@ import { getHikeContext } from './hikeContext.svelte'; import { focused } from './focusedImageStore.svelte'; import { addScrollAnchor } from './scrollAnchors'; + import { dev } from '$app/environment'; import Lock from '@lucide/svelte/icons/lock'; import Clock from '@lucide/svelte/icons/clock'; interface Props { /** Position in the hike's full chronological image list (0-indexed, - * stable across viewers because it refers to the unfiltered list). */ - idx: number; + * stable across viewers because it refers to the unfiltered list). + * Use this for route photos — it carries the map sync + elapsed time. */ + idx?: number; + /** Source filename of an image in the hike's `images/` dir, for an + * inline prose photo that isn't a route waypoint. Mutually exclusive + * with `idx`. A path is accepted; only the basename is used. */ + src?: string; + /** Alt text override for `src` mode. Falls back to the build-time alt. */ + alt?: string; + /** Marks a `src`-mode prose image as private (auth-gated + lock badge). + * Read at BUILD time from the prose by build-hikes — it encodes the image + * into the gated `private/` segment. At runtime the component takes the + * visibility from the manifest, so this prop is declarative only. */ + private?: boolean; /** Optional caption shown under the image — narrative blurb, not a - * machine-derived label. Elapsed time is shown automatically. */ + * machine-derived label. Elapsed time is shown automatically (idx mode). */ caption?: string; } - const { idx, caption }: Props = $props(); + const { idx, src, alt, caption }: Props = $props(); const ctx = getHikeContext(); - const ip = $derived(ctx().images[idx]); + // Prose mode: resolve the named image, hiding private ones from viewers who + // may not see them (the gated endpoint would 401 anyway). + const named = $derived.by(() => { + if (!src) return undefined; + const name = src.split('/').pop() ?? src; + const n = ctx().imagesByName[name]; + if (!n) return undefined; + if (n.visibility === 'private' && !ctx().showPrivate) return undefined; + return n; + }); + + $effect(() => { + if (dev && src && !ctx().imagesByName[src.split('/').pop() ?? src]) { + console.warn( + `[HikeImage] No image named "${src}" in this hike. Put it in the hike's ` + + `images/ folder, reference it in the prose, and re-run build-hikes.` + ); + } + }); + + const ip = $derived(idx === undefined ? undefined : ctx().images[idx]); const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false); - const visibleIdx = $derived(visible ? ctx().visibleImages.indexOf(ip) : -1); + const visibleIdx = $derived(visible && ip ? ctx().visibleImages.indexOf(ip) : -1); const isActive = $derived(visibleIdx >= 0 && focused.index === visibleIdx); // Find the track point closest in time to this image. Used by the @@ -80,7 +113,33 @@ }); -{#if ip && visible} +{#if src} + {#if named} +
+ + + + {alt + + {#if named.visibility === 'private'} + + + {/if} + {#if caption} +
{caption}
+ {/if} +
+ {/if} +{:else if ip && visible}
` to compute the nearest-track-index for the * scroll-progress pin on the map. */ readonly track: HikeTrackPoint[] | null; + /** Images addressable by source filename for ``, + * keyed by source basename. */ + readonly imagesByName: Record; + /** Whether the current viewer may see private images. Path-mode + * `` hides private images when this is false. */ + readonly showPrivate: boolean; } export function setHikeContext(ctx: () => HikeContext): void { diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index a7e8416c..78d0ebba 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -159,7 +159,9 @@ setHikeContext(() => ({ images: hike.imagePoints, visibleImages: visibleImagePoints, - track + track, + imagesByName: hike.imagesByName ?? {}, + showPrivate })); // Continuous trail-position tracking. As the reader scrolls through the diff --git a/src/routes/private-images/[...file]/+server.ts b/src/routes/private-images/[...file]/+server.ts new file mode 100644 index 00000000..ac188086 --- /dev/null +++ b/src/routes/private-images/[...file]/+server.ts @@ -0,0 +1,75 @@ +import { error } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { RequestHandler } from './$types'; + +/** + * Gates `/private-images/` requests behind an authenticated session. + * These are the encoded variants produced by scripts/build-private-images.ts + * and referenced from via src/lib/data/privateImages.generated.ts. + * + * Production: returns `X-Accel-Redirect` so nginx serves the bytes from + * `/var/www/static/private-images/` via an `internal` location. Node never + * touches the file. Add this once to the server's nginx config: + * + * location /protected-images/ { + * internal; + * alias /var/www/static/private-images/; + * } + * + * Dev (`vite dev`): no nginx in front, so stream the file from the local + * `private-assets/` tree. The auth check is identical either way. + */ +const MIME: Record = { + '.avif': 'image/avif', + '.webp': 'image/webp', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png' +}; + +export const GET: RequestHandler = async ({ locals, params }) => { + const session = locals.session ?? (await locals.auth()); + if (!session?.user) { + throw error(401, 'Authentication required.'); + } + + const file = params.file; + // `[...file]` may contain `/` for nested sources; reject `..` traversal. + if (!file || file.includes('..')) { + throw error(400, 'Bad request.'); + } + + if (dev) { + const base = path.join(process.cwd(), 'private-assets'); + const filePath = path.join(base, file); + // Defensive: ensure the resolved path stays inside private-assets/. + if (filePath !== base && !filePath.startsWith(base + path.sep)) { + throw error(400, 'Bad request.'); + } + try { + const data = await fs.readFile(filePath); + const mime = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream'; + return new Response(new Uint8Array(data), { + status: 200, + headers: { + 'Content-Type': mime, + 'Cache-Control': 'private, max-age=60' + } + }); + } catch { + throw error(404, 'Image not found.'); + } + } + + return new Response(null, { + status: 200, + headers: { + // nginx replaces the body with the file from the `internal` location; + // the Content-Type it sets there wins, so we don't guess one here. + 'X-Accel-Redirect': `/protected-images/${file}`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/types/hikes.ts b/src/types/hikes.ts index ef00a405..1d98df10 100644 --- a/src/types/hikes.ts +++ b/src/types/hikes.ts @@ -24,6 +24,14 @@ export type ImagePoint = { visibility?: 'public' | 'private'; }; +/** A hike image addressable by its source filename, for `` + * used inline in the prose. Unlike `imagePoints` these need not be route + * waypoints (so no lat/lng/timestamp), but they carry the full responsive + * `srcset` so prose photos still get every quality level. */ +export type NamedHikeImage = ImageVariant & { + visibility: 'public' | 'private'; +}; + // [lng, lat, elevation?, unixMs?] export type HikeTrackPoint = [number, number, number?, number?]; @@ -134,6 +142,11 @@ export type HikeManifestEntry = { // Geo-tagged photos shown as map markers on the detail page: imagePoints: ImagePoint[]; + + /** Images addressable by source filename for inline ``. + * Contains every encoded route photo plus any non-waypoint image referenced + * by name in the prose (index.svx). Keyed by source basename. */ + imagesByName?: Record; }; /** Pre-rendered hero map for the `/hikes` index page. One image covers diff --git a/src/types/images.ts b/src/types/images.ts new file mode 100644 index 00000000..8b067f5d --- /dev/null +++ b/src/types/images.ts @@ -0,0 +1,18 @@ +/** Responsive variant for a private (auth-gated) image, produced at build time + * by scripts/build-private-images.ts and consumed by Image.svelte. + * + * All URLs point at `/private-images/` — the auth-checked endpoint in + * src/routes/private-images/[...file]/+server.ts, NOT a public asset. Public + * images take the opposite path (enhanced-img), so they have no manifest. */ +export type PrivateImageVariant = { + /** Largest WebP, used as the fallback `src`. */ + src: string; + /** AVIF candidates as a `srcset` string (`url 480w, url 960w, …`). */ + srcsetAvif: string; + /** WebP candidates as a `srcset` string. */ + srcsetWebp: string; + /** Intrinsic width/height of the largest variant — set on the so the + * browser reserves space and avoids layout shift. */ + width: number; + height: number; +}; diff --git a/vite.config.ts b/vite.config.ts index b60c8776..345874bd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { enhancedImages } from '@sveltejs/enhanced-img'; import { defineConfig, type Plugin } from 'vite'; import { createReadStream, promises as fs } from 'node:fs'; import path from 'node:path'; @@ -68,7 +69,11 @@ export default defineConfig({ server: { allowedHosts: ["bocken.org"] }, - plugins: [hikeImagesDevPlugin(), sveltekit()], + // enhancedImages() powers (src/lib/components/Image.svelte): it runs + // vite-imagetools (sharp) over every image under src/lib/assets/images/ at + // build time, emitting AVIF/WebP at multiple widths. Must come before the + // SvelteKit plugin. + plugins: [enhancedImages(), hikeImagesDevPlugin(), sveltekit()], optimizeDeps: { exclude: ['barcode-detector'] },