From 922302ce5c3443e8342a56692ce44fce7079547c Mon Sep 17 00:00:00 2001 From: ralksta Date: Sun, 15 Mar 2026 07:32:42 +0100 Subject: [PATCH] chore: implement audit action items and translate hardcoded German text --- app/api/image/[id]/route.ts | 4 +- app/layout.tsx | 75 ++------------------------------- app/page.tsx | 28 ++++++++++-- components/DevToolbarLoader.tsx | 6 +-- components/MapView.tsx | 11 ++++- components/SetupScreen.tsx | 75 +++++++++++++++++++++++++++++++++ docs/gallery-config.md | 8 ++-- docs/ideas.md | 2 +- lib/__tests__/config.test.ts | 6 +-- lib/config/index.ts | 31 ++++++++------ lib/config/schema.ts | 15 +++++++ lib/immich.ts | 10 ++--- lib/tokens.ts | 4 ++ middleware.ts | 52 +++++++++++++++++++++++ next.config.ts | 15 +------ package.json | 2 +- 16 files changed, 222 insertions(+), 122 deletions(-) create mode 100644 components/SetupScreen.tsx create mode 100644 middleware.ts diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 1b4248b..3214620 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -38,7 +38,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (!success) { const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); const userAgent = request.headers.get('user-agent') || 'unknown'; - console.warn(`[Image API] ⚠️ Rate limit exceeded for IP: ${ip} (UA: ${userAgent}). Retry after ${retryAfter}s`); + 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 }); } diff --git a/app/layout.tsx b/app/layout.tsx index ba7bf45..15362eb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,8 +11,9 @@ import { SubpageNav } from '@/components/SubpageNav'; import { ThemeToggle } from '@/components/ThemeToggle'; import { ScrollToTop } from '@/components/ScrollToTop'; import { Footer } from '@/components/Footer'; +import { SetupScreen } from '@/components/SetupScreen'; import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config'; -// DevToolbarLoader ist eine Client Component (ssr: false nur dort erlaubt) +// DevToolbarLoader is a Client Component (ssr: false is only allowed there) import { DevToolbarLoader } from '@/components/DevToolbarLoader'; export async function generateMetadata(): Promise { @@ -66,75 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( -
-
-

- Setup Required -

-

- Immich Folio is running, but it looks like your configuration files or environment - variables are missing. -

-
-

- To get started, follow these steps in your repository or mounted{' '} - content/ volume: -

-
    -
  1. - Copy .env.example to .env.local and fill in your - Immich API URL and Key -
  2. -
  3. - Copy settings.yaml.example to settings.yaml -
  4. -
  5. - Copy gallery.yaml.example to gallery.yaml -
  6. -
  7. - Copy about.md.example to about.md (optional) -
  8. -
-

- Open gallery.yaml and add the album IDs you want to display, then{' '} - restart your server. -

-
-
-
+ ); @@ -142,7 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return (
- +
); @@ -203,7 +208,12 @@ export default async function HomePage() { return (
- +
); @@ -215,7 +225,12 @@ export default async function HomePage() {
- +
); @@ -227,7 +242,12 @@ export default async function HomePage() { {/* ── Left Panel ──────────────────────────────── */}
- +
diff --git a/components/DevToolbarLoader.tsx b/components/DevToolbarLoader.tsx index 7d2f179..beba985 100644 --- a/components/DevToolbarLoader.tsx +++ b/components/DevToolbarLoader.tsx @@ -2,10 +2,10 @@ import dynamic from 'next/dynamic'; -// ssr: false ist nur in Client Components erlaubt +// ssr: false is only allowed in Client Components const DevToolbar = dynamic( - () => import('@/components/DevToolbar').then(m => ({ default: m.DevToolbar })), - { ssr: false } + () => import('@/components/DevToolbar').then((m) => ({ default: m.DevToolbar })), + { ssr: false }, ); export function DevToolbarLoader() { diff --git a/components/MapView.tsx b/components/MapView.tsx index 4b6ff53..03e75a4 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -46,7 +46,11 @@ export function MapView() { const res = await fetch('/api/map'); if (!res.ok) { let detail = ''; - try { detail = await res.text(); } catch { /* ignore */ } + try { + detail = await res.text(); + } catch { + /* ignore */ + } console.error('[Map] API error', res.status, detail); setError(`Failed to load map data (${res.status})`); setLoading(false); @@ -91,7 +95,10 @@ export function MapView() { }); const albumLinks = loc.albums - .map((a) => `${escapeHtml(a.name)} →`) + .map( + (a) => + `${escapeHtml(a.name)} →`, + ) .join(''); const popupHtml = ` diff --git a/components/SetupScreen.tsx b/components/SetupScreen.tsx new file mode 100644 index 0000000..30feb98 --- /dev/null +++ b/components/SetupScreen.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +export function SetupScreen() { + return ( +
+
+

+ Setup Required +

+

+ Immich Folio is running, but it looks like your configuration files or environment + variables are missing. +

+
+

+ To get started, follow these steps in your repository or mounted content/{' '} + volume: +

+
    +
  1. + Copy .env.example to .env.local and fill in your Immich API + URL and Key +
  2. +
  3. + Copy settings.yaml.example to settings.yaml +
  4. +
  5. + Copy gallery.yaml.example to gallery.yaml +
  6. +
  7. + Copy about.md.example to about.md (optional) +
  8. +
+

+ Open gallery.yaml and add the album IDs you want to display, then{' '} + restart your server. +

+
+
+
+ ); +} diff --git a/docs/gallery-config.md b/docs/gallery-config.md index 260e748..41a03a7 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -102,16 +102,16 @@ albums: subpages: - name: Japan title: Japan - subtitle: 'Reisen durch ein Land voller Kontraste.' + subtitle: 'Travel through a land full of contrasts.' sections: - title: Tokyo - description: 'Megacity, Neonlichter, Stille im Lärm.' + description: 'Megacity, neon lights, silence in the noise.' albums: - '33333333-3333-3333-3333-333333333333' - - '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht + - '44444444-4444-4444-4444-444444444444': Shinjuku at night - title: Kyoto - description: 'Tempel und Bambuswälder.' + description: 'Temples and bamboo forests.' albums: - '55555555-5555-5555-5555-555555555555': Fushimi Inari - '66666666-6666-6666-6666-666666666666' diff --git a/docs/ideas.md b/docs/ideas.md index 3070883..53cf9d9 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -1,6 +1,6 @@ # Portfolio Backlog & Ideas -Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, bereinigt um alles, was bereits implementiert wurde. +A consolidated list of features and ideas for future versions, cleaned up from everything that has already been implemented. ## 🏆 Quick Wins (High Impact / Low Effort) diff --git a/lib/__tests__/config.test.ts b/lib/__tests__/config.test.ts index 35663fd..791015b 100644 --- a/lib/__tests__/config.test.ts +++ b/lib/__tests__/config.test.ts @@ -93,8 +93,8 @@ describe('buildSubpageGrid', () => { }); it('handles all fields together', () => { - expect( - buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' }), - ).toEqual({ grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' } }); + expect(buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' })).toEqual({ + grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' }, + }); }); }); diff --git a/lib/config/index.ts b/lib/config/index.ts index 8242d59..fe43ac2 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -6,6 +6,7 @@ import { AppConfig, SubpageConfig, SubpageSectionConfig, + SubpageObjectValue, GalleryYaml, SettingsYaml, GridConfig, @@ -17,9 +18,12 @@ export * from './theme'; let _config: AppConfig | null = null; /** Converts raw YAML grid overrides into a typed partial GridConfig. */ -export function buildSubpageGrid( - raw?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }, -): { grid: Partial } | Record { +export function buildSubpageGrid(raw?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; +}): { grid: Partial } | Record { if (!raw) return {}; return { grid: { @@ -43,13 +47,19 @@ export function getConfig(): AppConfig { const apiUrl = env.IMMICH_API_URL; const apiKey = env.IMMICH_API_KEY; - const authSecret = env.AUTH_SECRET || apiKey; - - if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { + let { AUTH_SECRET } = env; + if (!AUTH_SECRET) { + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'SECURITY ERROR: AUTH_SECRET is not set in production. Please set a long random string as AUTH_SECRET in your .env.', + ); + } console.warn( '\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n', ); + AUTH_SECRET = apiKey; } + const authSecret = AUTH_SECRET; const gallery = loadYaml('gallery.yaml'); const settings = loadYaml('settings.yaml') || {}; @@ -64,6 +74,7 @@ export function getConfig(): AppConfig { subpages: [], siteTitle: env.SITE_TITLE || 'Immich Folio', siteSubtitle: env.SITE_SUBTITLE || 'Setup Required', + lang: 'en', seo: { title: 'Setup Required', description: 'Please configure Immich Folio', @@ -169,13 +180,6 @@ export function getConfig(): AppConfig { albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), }; } - interface SubpageObjectValue { - title?: string; - subtitle?: string; - albums?: Array>; - password?: string; - grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; - } const sp = value as SubpageObjectValue; const albumEntries = sp.albums || []; return { @@ -201,6 +205,7 @@ export function getConfig(): AppConfig { subpages, siteTitle: settings.title ?? env.SITE_TITLE, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, + lang: settings.lang ?? 'en', seo: { title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', description: diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 0f2c867..641e51b 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -16,6 +16,19 @@ export interface SubpageConfig { grid?: Partial; } +export interface SubpageObjectValue { + title?: string; + subtitle?: string; + password?: string; + grid?: Partial; + albums?: Array>; + sections?: Array<{ + title: string; + description?: string; + albums: Array>; + }>; +} + export interface FooterConfig { name?: string; instagram?: string; @@ -62,6 +75,7 @@ export interface AppConfig { subpages: SubpageConfig[]; siteTitle: string; siteSubtitle: string; + lang: string; seo: { title: string; description: string; @@ -110,6 +124,7 @@ export interface GalleryYaml { export interface SettingsYaml { title?: string; subtitle?: string; + lang?: string; seo?: { title?: string; description?: string; diff --git a/lib/immich.ts b/lib/immich.ts index 2b0a30e..d3ba881 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -66,13 +66,13 @@ export interface SubpageSummary { coverAssetId: string | null; } -// Module-level flag so the album summary is only logged once even if the -// ImmichClient singleton is replaced in hot-reload scenarios. -let _hasLoggedAlbums = false; +// Note: Album summary logging state is kept in the client instance. // ── API Client ───────────────────────────────────────────────── class ImmichClient { + private hasLoggedAlbums = false; + private get config() { return getConfig(); } @@ -168,8 +168,8 @@ class ImmichClient { }); // Log album summary on first load so admins can see what's published - if (!_hasLoggedAlbums) { - _hasLoggedAlbums = true; + if (!this.hasLoggedAlbums) { + this.hasLoggedAlbums = true; console.log('\n[Lightbox] Published albums:'); console.log('─'.repeat(80)); diff --git a/lib/tokens.ts b/lib/tokens.ts index 093f76e..cc82bec 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -21,6 +21,10 @@ function getKey(): Buffer { /** * Encode an asset ID into an opaque URL-safe token. + * Note: The AES-CBC deterministic IV causes the identical input asset ID + * to encrypt to the exact same cipher token. This provides URL obfuscation + * and caching consistency, but it does NOT provide k-anonymous cryptographic + * security guarantees against recognizing identical items if intercepted. */ export function encodeAssetId(assetId: string): string { const key = getKey(); diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..106517e --- /dev/null +++ b/middleware.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + + // Define CSP directives + const cspDirectives = [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline'`, // 'unsafe-inline' is ignored by modern browsers if nonce/strict-dynamic is present, but kept for fallback + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com", + "connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-nonce', nonce); + requestHeaders.set('Content-Security-Policy', cspDirectives); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + response.headers.set('Content-Security-Policy', cspDirectives); + + return response; +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + { + source: '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, + ], +}; diff --git a/next.config.ts b/next.config.ts index 9bc5a64..3603284 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,18 +1,6 @@ import type { NextConfig } from 'next'; -// ── Content Security Policy ──────────────────────────────────── -// Locks down the public-facing site to only trusted sources. -const cspDirectives = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com", - "font-src 'self' https://fonts.gstatic.com", - "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com", // Added blob: and osm - "connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com", // Added fonts for connectivity checks - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", -].join('; '); +// Security headers configuration const nextConfig: NextConfig = { output: 'standalone', @@ -25,7 +13,6 @@ const nextConfig: NextConfig = { { source: '/(.*)', headers: [ - { key: 'Content-Security-Policy', value: cspDirectives }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Frame-Options', value: 'DENY' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, diff --git a/package.json b/package.json index d060c59..587616d 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@types/leaflet": "^1.9.21", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", "next": "16.1.6", @@ -26,6 +25,7 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/js-yaml": "^4.0.9", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19",