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