chore: implement audit action items and translate hardcoded German text

This commit is contained in:
ralksta
2026-03-15 07:32:42 +01:00
parent 3fa47f7031
commit 922302ce5c
16 changed files with 222 additions and 122 deletions

View File

@@ -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 });
}

View File

@@ -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<Metadata> {
@@ -66,75 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" suppressHydrationWarning>
<body>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100dvh',
padding: '2rem',
backgroundColor: '#ffffff',
color: '#111111',
fontFamily: 'system-ui, sans-serif',
}}
>
<div style={{ maxWidth: '600px', width: '100%' }}>
<h1
style={{
fontSize: 'clamp(2rem, 5vw, 3rem)',
fontWeight: 500,
marginBottom: '0.5rem',
letterSpacing: '-0.02em',
}}
>
Setup Required
</h1>
<p
style={{
fontSize: '1.125rem',
opacity: 0.7,
marginBottom: '2rem',
lineHeight: 1.5,
}}
>
Immich Folio is running, but it looks like your configuration files or environment
variables are missing.
</p>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.03)',
padding: '1.5rem',
borderRadius: '8px',
fontSize: '0.95rem',
lineHeight: 1.6,
}}
>
<p style={{ marginBottom: '1rem' }}>
To get started, follow these steps in your repository or mounted{' '}
<code>content/</code> volume:
</p>
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>.env.example</code> to <code>.env.local</code> and fill in your
Immich API URL and Key
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
</li>
</ol>
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
<strong>restart your server</strong>.
</p>
</div>
</div>
</div>
<SetupScreen />
</body>
</html>
);
@@ -142,7 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html
lang="de"
lang={config.lang || 'en'}
suppressHydrationWarning
style={themeVars as React.CSSProperties}
data-preset={theme.preset}

View File

@@ -192,7 +192,12 @@ export default async function HomePage() {
))}
</div>
<div className="hero__mosaic-overlay">
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
/>
</div>
</div>
);
@@ -203,7 +208,12 @@ export default async function HomePage() {
return (
<div className="hero hero--minimal">
<div className="hero__content">
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
/>
</div>
</div>
);
@@ -215,7 +225,12 @@ export default async function HomePage() {
<div className="hero hero--fullbleed">
<HeroCarousel images={heroData} />
<div className="hero__fullbleed-overlay">
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
/>
</div>
</div>
);
@@ -227,7 +242,12 @@ export default async function HomePage() {
{/* ── Left Panel ──────────────────────────────── */}
<div className="hero__left">
<div className="hero__content">
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
/>
</div>
</div>

View File

@@ -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() {

View File

@@ -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) => `<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`)
.map(
(a) =>
`<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`,
)
.join('');
const popupHtml = `

View File

@@ -0,0 +1,75 @@
import React from 'react';
export function SetupScreen() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100dvh',
padding: '2rem',
backgroundColor: '#ffffff',
color: '#111111',
fontFamily: 'system-ui, sans-serif',
}}
>
<div style={{ maxWidth: '600px', width: '100%' }}>
<h1
style={{
fontSize: 'clamp(2rem, 5vw, 3rem)',
fontWeight: 500,
marginBottom: '0.5rem',
letterSpacing: '-0.02em',
}}
>
Setup Required
</h1>
<p
style={{
fontSize: '1.125rem',
opacity: 0.7,
marginBottom: '2rem',
lineHeight: 1.5,
}}
>
Immich Folio is running, but it looks like your configuration files or environment
variables are missing.
</p>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.03)',
padding: '1.5rem',
borderRadius: '8px',
fontSize: '0.95rem',
lineHeight: 1.6,
}}
>
<p style={{ marginBottom: '1rem' }}>
To get started, follow these steps in your repository or mounted <code>content/</code>{' '}
volume:
</p>
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>.env.example</code> to <code>.env.local</code> and fill in your Immich API
URL and Key
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
</li>
</ol>
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
<strong>restart your server</strong>.
</p>
</div>
</div>
</div>
);
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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' },
});
});
});

View File

@@ -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<GridConfig> } | Record<string, never> {
export function buildSubpageGrid(raw?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
}): { grid: Partial<GridConfig> } | Record<string, never> {
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<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('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<string | Record<string, string>>;
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:

View File

@@ -16,6 +16,19 @@ export interface SubpageConfig {
grid?: Partial<GridConfig>;
}
export interface SubpageObjectValue {
title?: string;
subtitle?: string;
password?: string;
grid?: Partial<GridConfig>;
albums?: Array<string | Record<string, string>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<string | Record<string, string>>;
}>;
}
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;

View File

@@ -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));

View File

@@ -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();

52
middleware.ts Normal file
View File

@@ -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' },
],
},
],
};

View File

@@ -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' },

View File

@@ -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",