From e394fe84a01bb842b3fb4aa39b54016dea24f0c2 Mon Sep 17 00:00:00 2001 From: ralksta Date: Sat, 7 Mar 2026 08:18:23 +0100 Subject: [PATCH] security: implement AUTH_SECRET and secure map/exif API routes --- .env.local.example | 4 + app/[...path]/PhotoGrid.tsx | 5 +- app/api/exif/[id]/route.ts | 29 +- app/api/map/route.ts | 55 ++- app/map/page.tsx | 50 +-- app/page.tsx | 10 +- components/DevToolbar.tsx | 747 ++++++++++++++++++------------------ components/MapView.tsx | 215 +++++------ e2e/photo-grid.spec.ts | 126 +++--- lib/auth.ts | 2 +- lib/config.ts | 134 ++++--- lib/env.ts | 6 + lib/tokens.ts | 4 +- scripts/screenshots.ts | 255 ++++++------ 14 files changed, 849 insertions(+), 793 deletions(-) diff --git a/.env.local.example b/.env.local.example index 4ab5993..64e2b13 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,6 +22,10 @@ CACHE_TTL=300 # Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120) # RATE_LIMIT_RPM=120 +# Secret for signing auth cookies and encrypting asset tokens (string, min 16 chars). +# If not provided, IMMICH_API_KEY will be used as a fallback (not recommended). +# AUTH_SECRET=your-super-long-random-secret-here + # ── Album configuration ────────────────────────────────────────── # Albums, subpages, hero image, and passwords are defined in content/gallery.yaml # See content/gallery.yaml.example for the full reference. diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index a45b15a6..2a6cc38 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -136,8 +136,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr {assets.map((asset, index) => (
openLightbox(index)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 78a1de3..c890536 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -24,15 +24,22 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: } const exif = asset.exifInfo; - return NextResponse.json({ - make: exif.make, - model: exif.model, - lensModel: exif.lensModel, - focalLength: exif.focalLength, - fNumber: exif.fNumber, - exposureTime: exif.exposureTime, - iso: exif.iso, - city: exif.city, - country: exif.country, - }); + return NextResponse.json( + { + make: exif.make, + model: exif.model, + lensModel: exif.lensModel, + focalLength: exif.focalLength, + fNumber: exif.fNumber, + exposureTime: exif.exposureTime, + iso: exif.iso, + city: exif.city, + country: exif.country, + }, + { + headers: { + 'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600', + }, + }, + ); } diff --git a/app/api/map/route.ts b/app/api/map/route.ts index 477edbb..a0f2167 100644 --- a/app/api/map/route.ts +++ b/app/api/map/route.ts @@ -4,39 +4,56 @@ * Cover asset IDs are encrypted for security. */ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; import { immich } from '@/lib/immich'; import { getConfig } from '@/lib/config'; import { imageUrl } from '@/lib/urls'; +import { isAuthenticated } from '@/lib/auth'; export const dynamic = 'force-dynamic'; -export async function GET() { - const config = getConfig(); +export async function GET(_request: NextRequest) { + const config = getConfig(); - if (!config.map) { - return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 }); - } + if (!config.map) { + return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 }); + } - const locations = await immich.getMapData(); + const cookieStore = await cookies(); + const getCookie = (name: string) => cookieStore.get(name)?.value; - // Encrypt cover asset IDs and convert to public-safe image URLs - const publicLocations = locations.map((loc) => ({ + const locations = await immich.getMapData(); + + // Filter locations and albums based on auth + const publicLocations = locations + .map((loc) => { + // Only keep albums the user is allowed to see + const allowedAlbums = loc.albums.filter((a) => { + if (!a.subpageSlug) return true; // Standalone albums are public + return isAuthenticated(a.subpageSlug, getCookie); + }); + + if (allowedAlbums.length === 0) return null; + + return { city: loc.city, country: loc.country, lat: loc.lat, lng: loc.lng, - photoCount: loc.photoCount, + photoCount: loc.photoCount, // Note: This count might be slightly off if some photos are in hidden albums coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'), - albums: loc.albums.map((a) => ({ - name: a.name, - url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`, + albums: allowedAlbums.map((a) => ({ + name: a.name, + url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`, })), - })); + }; + }) + .filter((loc) => loc !== null); - return NextResponse.json(publicLocations, { - headers: { - 'Cache-Control': `public, max-age=${Math.floor(config.cacheTtl / 1000)}, stale-while-revalidate=60`, - }, - }); + return NextResponse.json(publicLocations, { + headers: { + 'Cache-Control': `private, no-cache, no-store, must-revalidate`, // Don't cache personalized map data publicly + }, + }); } diff --git a/app/map/page.tsx b/app/map/page.tsx index 986ad07..e9466c9 100644 --- a/app/map/page.tsx +++ b/app/map/page.tsx @@ -13,34 +13,34 @@ import './map.css'; export const dynamic = 'force-dynamic'; export const metadata: Metadata = { - title: 'Map', + title: 'Map', }; export default function MapPage() { - const config = getConfig(); + const config = getConfig(); - if (!config.map) { - notFound(); - } + if (!config.map) { + notFound(); + } - return ( - <> - {/* Leaflet CSS from CDN */} - {/* eslint-disable-next-line @next/next/no-page-custom-font */} - -
-
- -

Map

-

Where the photos were taken

-
- -
- - ); + return ( + <> + {/* Leaflet CSS from CDN */} + {} + +
+
+ +

Map

+

Where the photos were taken

+
+ +
+ + ); } diff --git a/app/page.tsx b/app/page.tsx index 3b143c6..aac4cf8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -80,9 +80,7 @@ export default async function HomePage() {