diff --git a/app/about/page.tsx b/app/about/page.tsx index fb69573..243706b 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -5,7 +5,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import matter from 'gray-matter'; +import yaml from 'js-yaml'; import Image from 'next/image'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { immich } from '@/lib/immich'; @@ -26,8 +26,21 @@ function getAboutContent() { return { meta: {} as AboutFrontmatter, body: '' }; } const raw = readFileSync(filePath, 'utf-8'); - const { data, content } = matter(raw); - return { meta: data as AboutFrontmatter, body: content.trim() }; + + let meta = {} as AboutFrontmatter; + let body = raw; + + const match = raw.match(/^(?:---\r?\n)([\s\S]*?)(?:\r?\n---\r?\n)([\s\S]*)$/); + if (match) { + try { + meta = (yaml.load(match[1]) || {}) as AboutFrontmatter; + } catch (e) { + console.error('Failed to parse about.md frontmatter', e); + } + body = match[2]; + } + + return { meta, body: body.trim() }; } export default async function AboutPage() { diff --git a/lib/auth.ts b/lib/auth.ts index 1ad4137..c01307f 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -7,18 +7,43 @@ */ 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. + * Check if a string looks like a legacy bcrypt hash. */ -function isHash(str: string): boolean { +function isBcryptHash(str: string): boolean { return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$'); } +/** + * Native SCrypt verify (format: 'scrypt:salt:hash_hex') + */ +function verifyScrypt(password: string, stored: string): boolean { + if (!stored.startsWith('scrypt:')) return false; + + try { + const [, salt, hashHex] = stored.split(':'); + const key = crypto.scryptSync(password, salt, 64); + const expectedKey = Buffer.from(hashHex, 'hex'); + if (key.length !== expectedKey.length) return false; + return crypto.timingSafeEqual(key, expectedKey); + } catch { + return false; + } +} + +/** + * Helper to generate an scrypt string to print in logs. + */ +function generateScryptHash(password: string): string { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, 64).toString('hex'); + return `scrypt:${salt}:${hash}`; +} + function hmac(data: string): string { return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex'); } @@ -56,15 +81,27 @@ export function authenticate(slug: string, password: string): string | null { if (!sp?.password) return null; let isValid = false; - if (isHash(sp.password)) { - isValid = bcrypt.compareSync(password, sp.password); + + if (isBcryptHash(sp.password)) { + console.error( + `\n❌ SECURITY ERROR: Subpage "${slug}" is using an outdated bcrypt password hash.\n` + + ` Bcrypt dependency has been removed to reduce bundle size.\n` + + ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` + + ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`, + ); + return null; + } + + if (sp.password.startsWith('scrypt:')) { + isValid = verifyScrypt(password, sp.password); } else { // Plaintext fallback (deprecated) isValid = password === sp.password; if (isValid) { + const recommendedHash = generateScryptHash(sp.password); 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`, + ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`, ); } } diff --git a/lib/env.ts b/lib/env.ts index 22b96bf..d052f3e 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,50 +1,58 @@ /** - * Zod-validated environment variables. + * Environment variables parser. * Parsed once at startup — all env access should go through this module. */ -import { z } from 'zod'; - -const envSchema = z.object({ - /** Base URL of your Immich instance, e.g. https://immich.example.com */ - IMMICH_API_URL: z - .string() - .url('IMMICH_API_URL must be a valid URL') - .transform((url) => url.replace(/\/+$/, '')), - - /** Immich API key (from Admin → API Keys) */ - IMMICH_API_KEY: z.string().min(1, 'IMMICH_API_KEY is required'), - - /** Public site title shown in the header */ - SITE_TITLE: z.string().default('Gallery'), - - /** Subtitle displayed below the title */ - SITE_SUBTITLE: z.string().default(''), - - /** Cache TTL in seconds */ - CACHE_TTL: z.coerce.number().int().min(0).default(300), - - /** Rate limit: max requests per minute per IP */ - RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(600), - - /** - * Secret used for signing auth cookies and encrypting asset tokens. - * If not provided, IMMICH_API_KEY will be used as a fallback (configured in config.ts). - */ - AUTH_SECRET: z.string().optional(), -}); - -export type Env = z.infer; +export interface Env { + IMMICH_API_URL: string; + IMMICH_API_KEY: string; + SITE_TITLE: string; + SITE_SUBTITLE: string; + CACHE_TTL: number; + RATE_LIMIT_RPM: number; + AUTH_SECRET?: string; +} function parseEnv(): Env { - const result = envSchema.safeParse(process.env); - if (!result.success) { - const formatted = result.error.issues - .map((issue) => ` ✗ ${issue.path.join('.')}: ${issue.message}`) - .join('\n'); + const errors: string[] = []; + + const urlRaw = process.env.IMMICH_API_URL; + let apiUrl = ''; + if (!urlRaw) { + errors.push('IMMICH_API_URL is required'); + } else { + try { + apiUrl = new URL(urlRaw).toString().replace(/\/+$/, ''); + } catch { + errors.push('IMMICH_API_URL must be a valid URL'); + } + } + + const apiKey = process.env.IMMICH_API_KEY; + if (!apiKey) { + errors.push('IMMICH_API_KEY is required'); + } + + if (errors.length > 0) { + const formatted = errors.map((err) => ` ✗ ${err}`).join('\n'); throw new Error(`\n❌ Environment validation failed:\n${formatted}\n`); } - return result.data; + + const cacheTtlStr = process.env.CACHE_TTL; + const cacheTtl = cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300; + + const rateLimitStr = process.env.RATE_LIMIT_RPM; + const rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600; + + return { + IMMICH_API_URL: apiUrl, + IMMICH_API_KEY: apiKey as string, + SITE_TITLE: process.env.SITE_TITLE || 'Gallery', + SITE_SUBTITLE: process.env.SITE_SUBTITLE || '', + CACHE_TTL: Math.max(0, cacheTtl), + RATE_LIMIT_RPM: Math.max(1, rateLimit), + AUTH_SECRET: process.env.AUTH_SECRET, + }; } /** Validated, typed environment variables. */ diff --git a/package-lock.json b/package-lock.json index a08cd4a..9749a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,21 +7,18 @@ "": { "name": "immich-lightbox", "version": "0.1.0", + "license": "MIT", "dependencies": { "@types/leaflet": "^1.9.21", - "bcryptjs": "^3.0.3", - "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "thumbhash": "^0.1.1", - "zod": "^4.3.6" + "thumbhash": "^0.1.1" }, "devDependencies": { "@playwright/test": "^1.58.2", - "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", @@ -2091,13 +2088,6 @@ "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", @@ -3199,15 +3189,6 @@ "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", @@ -4283,19 +4264,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4362,18 +4330,6 @@ "node": ">=12.0.0" } }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4724,43 +4680,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5089,15 +5008,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5518,15 +5428,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -6517,19 +6418,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6762,12 +6650,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6926,15 +6808,6 @@ "node": ">=4" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7765,6 +7638,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 6c55a89..d060c59 100644 --- a/package.json +++ b/package.json @@ -16,19 +16,15 @@ }, "dependencies": { "@types/leaflet": "^1.9.21", - "bcryptjs": "^3.0.3", - "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "thumbhash": "^0.1.1", - "zod": "^4.3.6" + "thumbhash": "^0.1.1" }, "devDependencies": { "@playwright/test": "^1.58.2", - "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", @@ -42,4 +38,4 @@ "typescript": "^5", "vitest": "^4.0.18" } -} \ No newline at end of file +}