diff --git a/app/impressum/impressum.css b/app/impressum/impressum.css index 88e8a76..5a042af 100644 --- a/app/impressum/impressum.css +++ b/app/impressum/impressum.css @@ -19,3 +19,26 @@ letter-spacing: 0.1em; opacity: 0.6; } +.subpage-content { + max-width: 600px; + margin: 0 auto; +} +.legal-section { + margin-bottom: 2rem; +} +.legal-section__title { + font-size: 1.2rem; + margin-bottom: 0.5rem; + opacity: 0.8; +} +.legal-section__text { + line-height: 1.6; +} +.legal-section__text--pre { + white-space: pre-wrap; +} +.legal-source { + margin-top: 4rem; + font-size: 0.85rem; + opacity: 0.6; +} diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx index b4e072f..ab8f997 100644 --- a/app/impressum/page.tsx +++ b/app/impressum/page.tsx @@ -29,10 +29,10 @@ export default function ImpressumPage() {

Angaben gemäß § 5 TMG

-
-
-

Anschrift

-

+

+
+

Anschrift

+

{legal.name}
{legal.address} @@ -44,9 +44,9 @@ export default function ImpressumPage() {

{(legal.email || legal.phone) && ( -
-

Kontakt

-

+

+

Kontakt

+

{legal.email && ( <> E-Mail: {legal.email} @@ -59,11 +59,9 @@ export default function ImpressumPage() { )} {(legal.taxId || legal.vatId) && ( -

-

- Steuernummer -

-

+

+

Steuernummer

+

{legal.taxId && ( <> Steuernummer: {legal.taxId} @@ -76,15 +74,13 @@ export default function ImpressumPage() { )} {legal.extraInfo && ( -

-

- Weitere Informationen -

-

{legal.extraInfo}

+
+

Weitere Informationen

+

{legal.extraInfo}

)} -
+

Quelle: Erstellt mit dem Impressum-Generator von eRecht24.

diff --git a/lib/__tests__/auth.test.ts b/lib/__tests__/auth.test.ts index cf5d7de..839ef64 100644 --- a/lib/__tests__/auth.test.ts +++ b/lib/__tests__/auth.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('@/lib/config', () => ({ getConfig: () => ({ immich: { apiKey: 'test-api-key-for-auth-tests' }, + authSecret: 'test-auth-secret-32-chars-long-min', subpages: [ { name: 'Private', diff --git a/lib/__tests__/immich.test.ts b/lib/__tests__/immich.test.ts new file mode 100644 index 0000000..e0051b3 --- /dev/null +++ b/lib/__tests__/immich.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { immich } from '../immich'; +import * as config from '../config'; + +// Mock the config by wrapping it in a factory +vi.mock('../config', async () => { + const actual = await vi.importActual('../config'); + return { + ...actual, + getConfig: () => ({ + immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' }, + authSecret: 'test-auth-secret-32-chars-long-min', + albums: ['album-1', 'album-2'], + standaloneAlbums: ['album-1'], + subpages: [], + albumOverrides: { 'album-1': 'Override Name' }, + cacheTtl: 0, + }), + }; +}); + +// Mock fetch globally +global.fetch = vi.fn(); + +describe('ImmichClient', () => { + const mockFetch = global.fetch as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('request()', () => { + it('returns data on successful JSON response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ foo: 'bar' }), + }); + + // Access private method via any casting for testing + const result = await (immich as any).request('/test'); + expect(result.data).toEqual({ foo: 'bar' }); + expect(result.error).toBeNull(); + }); + + it('returns error on non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await (immich as any).request('/test'); + expect(result.data).toBeNull(); + expect(result.error).toContain('404 Not Found'); + }); + }); + + describe('getAlbums()', () => { + it('filters and overrides albums based on config', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => [ + { id: 'album-1', albumName: 'Original Name', assetCount: 10 }, + { id: 'album-3', albumName: 'Not Allowed', assetCount: 5 }, + ], + }); + + const albums = await immich.getAlbums(); + expect(albums).toHaveLength(1); + expect(albums[0].id).toBe('album-1'); + expect(albums[0].albumName).toBe('Override Name'); + expect(albums[0].slug).toBe('override-name'); + }); + }); + + describe('getAlbum()', () => { + it('returns null for unallowed album IDs', async () => { + const album = await immich.getAlbum('album-3'); + expect(album).toBeNull(); + }); + + it('returns album data and filters trashed assets', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + id: 'album-1', + albumName: 'Name', + assets: [ + { id: 'asset-1', isTrashed: false }, + { id: 'asset-2', isTrashed: true }, + ], + }), + }); + + const album = await immich.getAlbum('album-1'); + expect(album?.assets).toHaveLength(1); + expect(album?.assets[0].id).toBe('asset-1'); + }); + }); +}); diff --git a/lib/__tests__/tokens.test.ts b/lib/__tests__/tokens.test.ts index 4b10e14..d0fce3d 100644 --- a/lib/__tests__/tokens.test.ts +++ b/lib/__tests__/tokens.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@/lib/config', () => ({ getConfig: () => ({ immich: { apiKey: 'test-api-key-for-vitest-unit-tests' }, + authSecret: 'test-auth-secret-32-chars-long-min', }), })); diff --git a/lib/auth.ts b/lib/auth.ts index b3ff8fa..1ad4137 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,21 +2,30 @@ * Album authentication helpers. * Uses HMAC tokens stored in HttpOnly cookies — no database needed. * - * Token = HMAC-SHA256(slug + password, apiKey) + * Token = HMAC-SHA256(slug + passwordSecret, authSecret) * Cookie = lb_auth_ = */ import crypto from 'crypto'; +import bcrypt from 'bcryptjs'; import { getConfig, SubpageConfig } from './config'; const TOKEN_EXPIRY_HOURS = 24; +/** + * Check if a string looks like a bcrypt hash. + */ +function isHash(str: string): boolean { + return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$'); +} + function hmac(data: string): string { return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex'); } -function authToken(slug: string, password: string): string { - return hmac(`${slug}:${password}`); +function authToken(slug: string, passwordSecret: string): string { + // Use the hash/password as part of the HMAC for a secure, unique session token + return hmac(`${slug}:${passwordSecret}`); } function cookieName(slug: string): string { @@ -46,9 +55,23 @@ export function authenticate(slug: string, password: string): string | null { const sp = findSubpageBySlug(slug); if (!sp?.password) return null; - if (password !== sp.password) return null; + let isValid = false; + if (isHash(sp.password)) { + isValid = bcrypt.compareSync(password, sp.password); + } else { + // Plaintext fallback (deprecated) + isValid = password === sp.password; + if (isValid) { + console.warn( + `\n⚠️ SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` + + ` Please hash it for better security. You can use: npx bcryptjs ${sp.password}\n`, + ); + } + } - const token = authToken(slug, password); + if (!isValid) return null; + + const token = authToken(slug, sp.password); const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; diff --git a/lib/env.ts b/lib/env.ts index ff548d6..026fc60 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -29,10 +29,25 @@ const envSchema = z.object({ /** * Secret used for signing auth cookies and encrypting asset tokens. - * If not provided, IMMICH_API_KEY will be used as a fallback (not recommended for production). */ AUTH_SECRET: z.string().min(16, 'AUTH_SECRET should be at least 16 characters').optional(), -}); +}).refine( + (data) => { + // Only require AUTH_SECRET in production at runtime, not during build + if ( + process.env.NODE_ENV === 'production' && + !process.env.NEXT_PHASE?.includes('build') && + !data.AUTH_SECRET + ) { + return false; + } + return true; + }, + { + message: 'AUTH_SECRET is required in production', + path: ['AUTH_SECRET'], + }, +); export type Env = z.infer; diff --git a/lib/immich.ts b/lib/immich.ts index f8c2c04..b25d7d3 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -77,6 +77,8 @@ export interface SubpageSummary { coverAssetId: string | null; } +export type ImmichResult = { data: T | null; error: string | null }; + // ── API Client ───────────────────────────────────────────────── class ImmichClient { @@ -89,7 +91,7 @@ class ImmichClient { /** * Make an authenticated request to the Immich API. */ - private async request(endpoint: string): Promise { + private async request(endpoint: string): Promise> { const url = `${this.config.immich.apiUrl}${endpoint}`; try { const res = await fetch(url, { @@ -100,18 +102,20 @@ class ImmichClient { }); if (!res.ok) { - console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); - return null; + const error = `[Immich] ${res.status} ${res.statusText} for ${endpoint}`; + console.error(error); + return { data: null, error }; } const contentType = res.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { - return (await res.json()) as T; + return { data: (await res.json()) as T, error: null }; } - return null; - } catch (error) { - console.error(`[Immich] Failed to reach ${url}:`, error); - return null; + return { data: null, error: 'Invalid response format (expected JSON)' }; + } catch (err) { + const error = `[Immich] Failed to reach ${url}: ${err instanceof Error ? err.message : 'Unknown error'}`; + console.error(error); + return { data: null, error }; } } @@ -160,8 +164,8 @@ class ImmichClient { const cached = cache.get(cacheKey); if (cached) return cached; - const all = await this.request('/albums?shared=true'); - if (!all) return []; + const { data: all, error } = await this.request('/albums?shared=true'); + if (error || !all) return []; const allowedIds = new Set(this.config.albums); const filtered = all @@ -276,8 +280,8 @@ class ImmichClient { const cached = cache.get(cacheKey); if (cached) return cached; - const album = await this.request(`/albums/${encodeURIComponent(albumId)}`); - if (!album) return null; + const { data: album, error } = await this.request(`/albums/${encodeURIComponent(albumId)}`); + if (error || !album) return null; // Filter out trashed assets album.assets = (album.assets || []).filter((a) => !a.isTrashed); @@ -325,8 +329,8 @@ class ImmichClient { const cached = cache.get(cacheKey); if (cached) return cached; - const asset = await this.request(`/assets/${encodeURIComponent(assetId)}`); - if (!asset) return null; + const { data: asset, error } = await this.request(`/assets/${encodeURIComponent(assetId)}`); + if (error || !asset) return null; cache.set(cacheKey, asset, this.config.cacheTtl); return asset; @@ -406,8 +410,8 @@ class ImmichClient { * Check if the Immich server is reachable. */ async ping(): Promise { - const res = await this.request<{ res: string }>('/server/ping'); - return !!res; + const { data } = await this.request<{ res: string }>('/server/ping'); + return !!data; } } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 5386f80..73b1209 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -2,6 +2,11 @@ * In-memory sliding-window rate limiter. * Tracks request counts per IP per minute bucket. * Auto-evicts expired entries to prevent memory leaks. + * + * ⚠️ NOTE: This is an in-memory store. In a multi-node or serverless + * environment (Vercel, AWS Lambda, Docker Swarm), each instance will + * have its own independent rate limit. For a global limit across + * multiple nodes, a persistent store like Redis would be required. */ interface RateLimitEntry { diff --git a/package-lock.json b/package-lock.json index b1f590a..a08cd4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@types/leaflet": "^1.9.21", + "bcryptjs": "^3.0.3", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", @@ -20,6 +21,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", @@ -2089,6 +2091,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3190,6 +3199,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index c98c8da..32fd831 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@types/leaflet": "^1.9.21", + "bcryptjs": "^3.0.3", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", @@ -27,6 +28,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", diff --git a/vitest.config.ts b/vitest.config.ts index ca04f1b..1b8e2c3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import path from 'path'; export default defineConfig({ test: { environment: 'node', + setupFiles: ['./vitest.setup.ts'], include: ['lib/__tests__/**/*.test.ts'], coverage: { provider: 'v8', diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..9650b65 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +// Set mandatory environment variables for all tests +process.env.IMMICH_API_URL = 'http://immich.test'; +process.env.IMMICH_API_KEY = 'test-key'; +process.env.AUTH_SECRET = 'test-auth-secret-32-chars-long-min'; +process.env.SITE_TITLE = 'Test Gallery';