diff --git a/.env.local.example b/.env.local.example index 255043a..d2cc2ea 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,6 +19,21 @@ SITE_SUBTITLE=A visual journal # Cache TTL in seconds (integer >= 0, default: 300 = 5 minutes) CACHE_TTL=300 +# Cache-buster for image and video URLs (any string, default: empty = off). +# Images are served with a one-year immutable cache, so browsers never +# revalidate them. If you regenerate thumbnails in Immich, or rotate/edit a +# photo, the asset ID stays the same and visitors keep seeing the old version. +# Bump this value (e.g. 1 → 2) to change every image URL at once and force a +# refetch. Leave it unset in normal operation. +# IMAGE_CACHE_VERSION=1 + +# How long to wait for a response from Immich, in milliseconds +# (integer, minimum 1000, default: 15000 = 15 seconds). +# Bounds the wait for a *response* only — downloading a large photo or a long +# video is not capped by this. Raise it if metadata search on very large albums +# times out; lower it to fail faster when Immich is unreachable. +# IMMICH_TIMEOUT_MS=15000 + # Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120) # RATE_LIMIT_RPM=120 diff --git a/CLAUDE.md b/CLAUDE.md index 7133589..59be0de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,9 @@ Singleton `ImmichClient` class exported as `immich`. All Immich API calls are se | `GET /api/admin/albums` | Browse all shared Immich albums (admin-only) | | `POST /api/admin/reload` | Invalidate config + Immich cache | -The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤250px→thumbnail`, `≤1440px→preview`, `>1440px→original`. +The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤250px→thumbnail`, `≤1440px→preview`, `>1440px→original` (`lib/imageSize.ts`). + +`?size=` (written by `lib/urls.ts`) acts as a **ceiling**, not an override. When both parameters are present the smaller tier wins, so `?w=` can narrow the request but never widen it — `next/image` emits widths up to 3840, so letting width win outright would serve full-size originals to every large display. ### Routing (`app/[...path]/page.tsx`) @@ -119,10 +121,12 @@ Visual page builder and settings editor at `/admin`. Enabled by setting `ADMIN_P After saving, `invalidateConfigCache()` is called so the next request picks up the new YAML without restart. -### Middleware (`middleware.ts`) +### Proxy (`proxy.ts`) Applies CSP (with per-request nonce), HSTS, and other security headers on all non-API, non-static routes. The nonce is passed to pages via the `x-nonce` request header. +Next.js 16 renamed the `middleware` file convention to `proxy`: the file is `proxy.ts` and it exports `proxy()` (not `middleware()`). The `config.matcher` export is unchanged. + ## Code conventions - **TypeScript strict mode** — no untyped `any`; use `@ts-expect-error` (not `@ts-ignore`) with a comment when suppressing type errors. diff --git a/README.md b/README.md index 0086b5b..61b301e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ IMMICH_API_KEY=your-api-key SITE_TITLE=My Photography # default: "Gallery" SITE_SUBTITLE=A visual journal # default: empty CACHE_TTL=300 # seconds, default: 300 +IMMICH_TIMEOUT_MS=15000 # Immich response wait, default: 15000 +IMAGE_CACHE_VERSION=1 # bump to bust browser image caches, default: off RATE_LIMIT_RPM=1500 # requests/min/IP, default: 1500 AUTH_SECRET=long-random-string # required in production TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0 diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index d69bf11..0288e57 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -18,7 +18,6 @@ export interface PhotoItem { type: 'image' | 'video'; thumbUrl: string; previewUrl: string; - originalUrl: string; videoUrl?: string; exifUrl: string; blurDataURL?: string; diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 1792a61..7e014d0 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -10,13 +10,13 @@ * with back-link to the subpage */ -import Link from 'next/link'; -import Image from 'next/image'; import { cookies } from 'next/headers'; import type { Metadata } from 'next'; import { immich, type ImmichAsset } from '@/lib/immich'; import { notFound } from 'next/navigation'; -import { PhotoGrid, type PhotoItem } from './PhotoGrid'; +// Only the type: rendering moved to AlbumDetailView/SubpageGridView, which +// import the component themselves. +import type { PhotoItem } from './PhotoGrid'; import { imageUrl, exifUrl, @@ -29,7 +29,6 @@ import { encodeAssetId } from '@/lib/tokens'; import { getConfig, type GridConfig } from '@/lib/config'; import { isProtected, isAuthenticated } from '@/lib/auth'; import PasswordGate from '@/components/PasswordGate'; -import { BackLink } from '@/components/BackLink'; import { AlbumDetailView } from './AlbumDetailView'; import { SubpageGridView } from './SubpageGridView'; @@ -103,7 +102,6 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] { type: isVideo ? 'video' : 'image', thumbUrl: imageUrl(a.id, 'preview'), previewUrl: imageUrl(a.id, 'preview'), - originalUrl: imageUrl(a.id, 'original'), ...(isVideo ? { videoUrl: videoUrl(a.id) } : {}), exifUrl: exifUrl(a.id), ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 828941e..54ca9b7 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -8,7 +8,7 @@ export const metadata: Metadata = { /** * Required for the CSP nonce. The admin page is a pure client component, so * Next.js would statically prerender it at build time — and a prerendered HTML - * file cannot carry the per-request nonce that middleware.ts issues. Since the + * file cannot carry the per-request nonce that proxy.ts issues. Since the * CSP uses 'strict-dynamic' (which makes 'self' inert), unnonced script tags * are blocked and the panel never hydrates. Rendering per request lets Next.js * stamp the nonce onto the script tags it emits. diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index aa97e44..b167b68 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -7,7 +7,7 @@ import { COOKIE_NAME, SESSION_DURATION_MS, } from '@/lib/admin/auth'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; /** Admin login attempts per minute per IP — the highest-value credential in the app. */ const ADMIN_AUTH_RPM = 5; @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { { error: 'Too many requests' }, { status: 429, - headers: { 'Retry-After': String(Math.ceil((rl.resetAt - Date.now()) / 1000)) }, + headers: { 'Retry-After': String(retryAfterSeconds(rl.resetAt)) }, }, ); } diff --git a/app/api/admin/gallery/route.ts b/app/api/admin/gallery/route.ts index aa39ae0..3374f23 100644 --- a/app/api/admin/gallery/route.ts +++ b/app/api/admin/gallery/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { revalidatePath } from 'next/cache'; import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service'; -import { invalidateConfigCache } from '@/lib/config'; +import { invalidateConfigCache, deriveGallery } from '@/lib/config'; import { immich } from '@/lib/immich'; import type { GalleryYaml } from '@/lib/config/schema'; @@ -39,13 +39,34 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Invalid hero format' }, { status: 400 }); } + // Run the *real* derivation before writing. getConfig() throws on a gallery + // with no albums and no subpages, a subpage with no name, or a subpage with + // neither albums nor sections — all of which the page builder can produce. + // Writing one used to report "Saved successfully" and then take the public + // site down until someone noticed. + // + // Calling deriveGallery rather than re-checking those conditions here is + // deliberate: a second copy of the rules would drift from the first. So is + // validating before the write rather than rolling back after — a rollback + // leaves a window in which other requests read the broken config. + try { + deriveGallery(gallery); + } catch (err) { + const reason = err instanceof Error ? err.message : 'Invalid gallery structure'; + console.warn('[Admin] Rejected an unloadable gallery.yaml:', reason); + return NextResponse.json({ error: reason }, { status: 400 }); + } + try { await writeGalleryYaml(gallery); invalidateConfigCache(); immich.invalidateAll(); // Revalidate all pages so the homepage picks up new hero images immediately revalidatePath('/', 'layout'); - return NextResponse.json({ success: true, message: 'Saved successfully. Backup of previous version created.' }); + return NextResponse.json({ + success: true, + message: 'Saved successfully. Backup of previous version created.', + }); } catch (err) { console.error('[Admin] Failed to write gallery.yaml:', err); return NextResponse.json({ error: 'Failed to save gallery config' }, { status: 500 }); diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 8e709a4..c12983c 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticate, isProtected } from '@/lib/auth'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; /** Tight limit for auth attempts — 10 per minute per IP. */ const AUTH_RPM = 10; @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { { status: 429, headers: { - 'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)), + 'Retry-After': String(retryAfterSeconds(resetAt)), 'X-RateLimit-Limit': String(AUTH_RPM), 'X-RateLimit-Remaining': String(remaining), }, @@ -59,7 +59,7 @@ export async function POST(request: NextRequest) { ); } - const setCookie = authenticate(slug, password, type); + const setCookie = await authenticate(slug, password, type); if (!setCookie) { return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); } diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 59f7ce6..3c48435 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -6,10 +6,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { immich } from '@/lib/immich'; +import { immich, ImmichUnavailableError } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const config = getConfig(); @@ -24,9 +24,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { success, resetAt } = checkRateLimit(`exif:${ip}`, config.rateLimitRpm); if (!success) { - const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + const retryAfter = retryAfterSeconds(resetAt); console.warn(`[EXIF API] ⚠️ Rate limit exceeded for IP: ${ip}. Retry after ${retryAfter}s`); - return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfter), 'Cache-Control': 'no-store' }, + }, + ); } const { id: token } = await params; @@ -37,7 +43,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); } - const asset = await immich.getAssetInfo(assetId); + let asset; + try { + asset = await immich.getAssetInfo(assetId); + } catch (error) { + // Immich is down, not "this asset has no EXIF" — say so, so the lightbox + // can retry rather than caching a 404 for a photo that does have data. + if (error instanceof ImmichUnavailableError) { + console.error(`[EXIF API] Immich unavailable for ${assetId}:`, error.message); + return NextResponse.json( + { error: 'Immich is currently unavailable' }, + { status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } }, + ); + } + throw error; + } + if (!asset?.exifInfo) { return NextResponse.json({ error: 'No EXIF data' }, { status: 404 }); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 168d89d..bed8aed 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { immich } from '@/lib/immich'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; import { getConfig } from '@/lib/config'; const startTime = Date.now(); @@ -30,7 +30,7 @@ export async function GET(request: NextRequest) { { status: 429, headers: { - 'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)), + 'Retry-After': String(retryAfterSeconds(resetAt)), 'X-RateLimit-Limit': String(rateLimitRpm), 'X-RateLimit-Remaining': String(remaining), }, diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index f28c4ff..c4bd654 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -8,21 +8,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { immich, ImageSize } from '@/lib/immich'; +import { immich, ImmichUnavailableError } from '@/lib/immich'; +import { resolveImageSize } from '@/lib/imageSize'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; - -const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original']; - -/** - * Map a requested pixel width to the best Immich size tier. - */ -function widthToSize(w: number): ImageSize { - if (w <= 250) return 'thumbnail'; - if (w <= 1440) return 'preview'; - return 'original'; -} +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { // ── Rate limiting ────────────────────────────────── @@ -30,12 +20,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { success, remaining, resetAt } = checkRateLimit(`image:${ip}`, getConfig().rateLimitRpm); if (!success) { - const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + const retryAfter = retryAfterSeconds(resetAt); const userAgent = request.headers.get('user-agent') || 'unknown'; console.warn( `[Image API] ⚠️ Rate limit exceeded for IP: ${ip} (UA: ${userAgent}). Retry after ${retryAfter}s`, ); - return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + // Without Retry-After the browser and next/image back off on their own + // schedule — a retry storm against a server that just said it was + // overloaded, and one page issues ~50 of these requests. no-store because + // the success path serves this URL as `immutable`. + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfter), 'Cache-Control': 'no-store' }, + }, + ); } const { id: token } = await params; @@ -47,23 +47,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); } - // Determine size: prefer explicit ?size=, then infer from ?w= - const sizeParam = request.nextUrl.searchParams.get('size'); - const widthParam = request.nextUrl.searchParams.get('w'); - - let size: ImageSize = 'preview'; - if (sizeParam && VALID_SIZES.includes(sizeParam as ImageSize)) { - size = sizeParam as ImageSize; - } else if (widthParam) { - const w = parseInt(widthParam, 10); - if (!isNaN(w) && w > 0) { - size = widthToSize(w); - } - } + const size = resolveImageSize( + request.nextUrl.searchParams.get('size'), + request.nextUrl.searchParams.get('w'), + ); // ── Browser Cache Optimization ───────────────────── - // Use the opaque token to generate a safe ETag without leaking Immich UUIDs - const etag = `W/"${token}-${size}"`; + // Use the opaque token to generate a safe ETag without leaking Immich UUIDs. + // IMAGE_CACHE_VERSION participates so that a bump is not defeated by a client + // replaying the old ETag against the new URL — the response is served + // `immutable`, so this only matters after expiry or a cache eviction, but a + // matching ETag across two different URLs would be wrong either way. + const cacheVersion = request.nextUrl.searchParams.get('v') ?? ''; + const etag = `W/"${token}-${size}${cacheVersion ? `-${cacheVersion}` : ''}"`; if (request.headers.get('if-none-match') === etag) { return new NextResponse(null, { status: 304, @@ -75,10 +71,29 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }); } - const result = await immich.streamAsset(assetId, size); + let result; + try { + result = await immich.streamAsset(assetId, size); + } catch (error) { + // An outage must not look like a deleted photo. These URLs are served with + // `immutable` on success, and a bare 404 is heuristically cacheable — so + // without no-store the browser can pin a broken image for the whole session. + if (error instanceof ImmichUnavailableError) { + console.error(`[Image API] Immich unavailable for ${assetId}:`, error.message); + return NextResponse.json( + { error: 'Immich is currently unavailable' }, + { status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } }, + ); + } + throw error; + } + if (!result) { console.error(`[Image API] ❌ Asset not found in Immich: ${assetId} (Size: ${size})`); - return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + return NextResponse.json( + { error: 'Asset not found' }, + { status: 404, headers: { 'Cache-Control': 'no-store' } }, + ); } let contentType = result.contentType.toLowerCase(); diff --git a/app/api/map/route.ts b/app/api/map/route.ts index 00d244c..f789512 100644 --- a/app/api/map/route.ts +++ b/app/api/map/route.ts @@ -10,7 +10,8 @@ import { getConfig } from '@/lib/config'; import { imageUrl } from '@/lib/urls'; import { isAuthenticated } from '@/lib/auth'; import { getMapData } from '@/lib/mapService'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { ImmichUnavailableError } from '@/lib/immich'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; export const dynamic = 'force-dynamic'; @@ -26,7 +27,7 @@ export async function GET(request: NextRequest) { { status: 429, headers: { - 'Retry-After': String(Math.ceil((rl.resetAt - Date.now()) / 1000)), + 'Retry-After': String(retryAfterSeconds(rl.resetAt)), 'X-RateLimit-Limit': '120', 'X-RateLimit-Remaining': '0', }, @@ -43,7 +44,21 @@ export async function GET(request: NextRequest) { const cookieStore = await cookies(); const getCookie = (name: string) => cookieStore.get(name)?.value; - const locations = await getMapData(); + let locations; + try { + locations = await getMapData(); + } catch (error) { + // Without this the map would render as "no photos anywhere" during an + // Immich outage, which is indistinguishable from a gallery with no GPS data. + if (error instanceof ImmichUnavailableError) { + console.error('[Map API] Immich unavailable:', error.message); + return NextResponse.json( + { error: 'Immich is currently unavailable' }, + { status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } }, + ); + } + throw error; + } // Filter locations and albums based on auth. // An album is visible only if BOTH its subpage gate (if any) and its own diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 7d786a5..ae6d5ad 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -9,7 +9,7 @@ import { ImageResponse } from 'next/og'; import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; /** * OG image rendering runs satori + resvg per request and each unique ?title @@ -23,9 +23,15 @@ export async function GET(request: NextRequest) { const ip = getClientIp(request); const { theme } = getConfig(); - const { success } = checkRateLimit(`og:${ip}`, OG_RPM); + const { success, resetAt } = checkRateLimit(`og:${ip}`, OG_RPM); if (!success) { - return new NextResponse('Too many requests', { status: 429 }); + return new NextResponse('Too many requests', { + status: 429, + headers: { + 'Retry-After': String(retryAfterSeconds(resetAt)), + 'Cache-Control': 'no-store', + }, + }); } const { searchParams } = request.nextUrl; diff --git a/app/api/video/[id]/route.ts b/app/api/video/[id]/route.ts index 37118d0..9eafbc1 100644 --- a/app/api/video/[id]/route.ts +++ b/app/api/video/[id]/route.ts @@ -10,10 +10,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { immich } from '@/lib/immich'; +import { immich, ImmichUnavailableError } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { // ── Rate limiting ────────────────────────────────── @@ -21,9 +21,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { success, remaining, resetAt } = checkRateLimit(`video:${ip}`, getConfig().rateLimitRpm); if (!success) { - const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + const retryAfter = retryAfterSeconds(resetAt); console.warn(`[Video API] ⚠️ Rate limit exceeded for IP: ${ip}. Retry after ${retryAfter}s`); - return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfter), 'Cache-Control': 'no-store' }, + }, + ); } const { id: token } = await params; @@ -38,10 +44,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Forward Range header from browser (needed for