feat(images): responsive <Image>, gated private images + prose
Build-time image optimization plus auth-gated private content. - <Image> (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/<slug>/private/ path. - <Private> (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.
This commit is contained in:
+60
-17
@@ -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<string, GpxImageRef>
|
||||
gpxImageRefs: Record<string, GpxImageRef>,
|
||||
/** 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 `<bocken:image hash>` 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 `<bocken:image hash>` waypoint) or by referencing its filename
|
||||
// inline with `<HikeImage src="…">`.
|
||||
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 `<HikeImage>` 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<HikeManifes
|
||||
} catch {
|
||||
// no images dir is fine
|
||||
}
|
||||
// Images whose content hash isn't in gpxImageRefs are dropped before
|
||||
// encoding (see processImage). Count for the log line below.
|
||||
// Images addressed inline with `<HikeImage src="…">` 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<string, 'public' | 'private'>();
|
||||
for (const m of svxSource.matchAll(/<HikeImage\b[^>]*?\/?>/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<HikeManifes
|
||||
};
|
||||
|
||||
type ImageResult = {
|
||||
name: string;
|
||||
variant: ImageVariant | null;
|
||||
point: ImagePoint | null;
|
||||
outNames: string[];
|
||||
@@ -1197,25 +1224,33 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
||||
IMAGE_CONCURRENCY,
|
||||
async (imgPath, i) => {
|
||||
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<HikeManifes
|
||||
}
|
||||
);
|
||||
|
||||
// Images addressable by source filename via `<HikeImage src="…">`. Only the
|
||||
// prose-referenced ones — keyed by basename, carrying the full srcset.
|
||||
const imagesByName: Record<string, NamedHikeImage> = {};
|
||||
|
||||
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<HikeManifes
|
||||
if (cover === null && r.visibility === 'public') cover = r.variant;
|
||||
const segment = r.visibility === 'private' ? 'private' : 'images';
|
||||
for (const name of r.outNames) keepFiles[segment].add(name);
|
||||
if (proseImages.has(r.name)) {
|
||||
imagesByName[r.name] = { ...r.variant, visibility: r.visibility };
|
||||
}
|
||||
}
|
||||
if (r.point) imagePoints.push(r.point);
|
||||
}
|
||||
@@ -1393,7 +1435,8 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
||||
heroMapUrlDarkNarrow,
|
||||
heroMapZoomNarrow,
|
||||
heroMapCenterNarrow,
|
||||
imagePoints
|
||||
imagePoints,
|
||||
...(Object.keys(imagesByName).length > 0 ? { imagesByName } : {})
|
||||
};
|
||||
|
||||
console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Build script for private (auth-gated) images rendered via `<Image private>`.
|
||||
*
|
||||
* 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<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(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<string[]> {
|
||||
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 <Image src="blog/cover.jpg" private />.
|
||||
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<string, PrivateImageVariant> = {};
|
||||
const keep = new Set<string>();
|
||||
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<string, PrivateImageVariant> = ${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);
|
||||
});
|
||||
+16
-1
@@ -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 <Image private>. 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"
|
||||
|
||||
Reference in New Issue
Block a user