diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e967662..3e44b10 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: "\U0001F41B Bug report" about: Something isn't working as expected -title: "[bug] " +title: '[bug] ' labels: bug assignees: '' --- @@ -26,13 +26,13 @@ What actually happened. Include screenshots if relevant. ## Environment -| Field | Value | -|---|---| -| immich-folio version | vX.X.X / commit SHA | -| Immich version | | -| Deployment | Docker / manual | -| Browser | Chrome / Firefox / Safari | -| OS | | +| Field | Value | +| -------------------- | ------------------------- | +| immich-folio version | vX.X.X / commit SHA | +| Immich version | | +| Deployment | Docker / manual | +| Browser | Chrome / Firefox / Safari | +| OS | | ## `content/gallery.yaml` (redacted) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 7df036d..9391889 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: "\U0001F4A1 Feature request" about: Suggest an idea or improvement -title: "[feat] " +title: '[feat] ' labels: enhancement assignees: '' --- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 97acb6f..cab2dd0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,7 @@ ## Summary + Closes # ## Type of change diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b3fd3e4..73f4736 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,41 +2,41 @@ version: 2 updates: # npm dependencies - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "Europe/Berlin" + interval: 'weekly' + day: 'monday' + time: '09:00' + timezone: 'Europe/Berlin' open-pull-requests-limit: 5 labels: - - "dependencies" + - 'dependencies' groups: # Batch minor/patch Next.js updates together nextjs: patterns: - - "next" - - "eslint-config-next" + - 'next' + - 'eslint-config-next' # Batch React updates react: patterns: - - "react" - - "react-dom" - - "@types/react*" + - 'react' + - 'react-dom' + - '@types/react*' ignore: # Don't auto-update major versions (review manually) - - dependency-name: "*" - update-types: ["version-update:semver-major"] + - dependency-name: '*' + update-types: ['version-update:semver-major'] # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "Europe/Berlin" + interval: 'weekly' + day: 'monday' + time: '09:00' + timezone: 'Europe/Berlin' labels: - - "dependencies" - - "github-actions" + - 'dependencies' + - 'github-actions' diff --git a/.planning/ui-reviews/UI-REVIEW.md b/.planning/ui-reviews/UI-REVIEW.md index 2127305..1fbffd7 100644 --- a/.planning/ui-reviews/UI-REVIEW.md +++ b/.planning/ui-reviews/UI-REVIEW.md @@ -8,14 +8,14 @@ ## Pillar Scores -| Pillar | Score | Key Finding | -|--------|-------|-------------| -| 1. Copywriting | 3/4 | Error states use generic 'Something went wrong'. | -| 2. Visuals | 3/4 | Standard component structures, but icon accessibility could be improved. | -| 3. Color | 2/4 | Heavy use of hardcoded colors in `DevToolbar.tsx`. | -| 4. Typography | 3/4 | Uses custom CSS styles rather than utility classes; mostly consistent. | -| 5. Spacing | 3/4 | Uses custom CSS styles; no major arbitrary inline spacing found. | -| 6. Experience Design | 3/4 | Good coverage of loading/error states in MapView and PasswordGate. | +| Pillar | Score | Key Finding | +| -------------------- | ----- | ------------------------------------------------------------------------ | +| 1. Copywriting | 3/4 | Error states use generic 'Something went wrong'. | +| 2. Visuals | 3/4 | Standard component structures, but icon accessibility could be improved. | +| 3. Color | 2/4 | Heavy use of hardcoded colors in `DevToolbar.tsx`. | +| 4. Typography | 3/4 | Uses custom CSS styles rather than utility classes; mostly consistent. | +| 5. Spacing | 3/4 | Uses custom CSS styles; no major arbitrary inline spacing found. | +| 6. Experience Design | 3/4 | Good coverage of loading/error states in MapView and PasswordGate. | **Overall: 17/24** @@ -32,30 +32,37 @@ ## Detailed Findings ### Pillar 1: Copywriting (3/4) + - **PasswordGate.tsx**: Uses standard CTA ('Enter', 'Verifying...') but error state falls back to generic "Something went wrong" at line 40. ### Pillar 2: Visuals (3/4) + - Components use semantic HTML for the most part. SVG icons in `ThemeToggle`, `Lightbox`, and `Footer` lack robust `aria-label` alternatives for screen readers in some instances. ### Pillar 3: Color (2/4) + - **DevToolbar.tsx**: Contains over 20 distinct hardcoded hex values (clues: `#e60012`, `#e4e4e7`, `#166534`, `#ff6b35`). This violates the single-source-of-truth theme approach. - **Lightbox / SetupScreen**: Minimal hardcoded values used for fallback (`#000`, `#ffffff`). ### Pillar 4: Typography (3/4) + - Exclusively uses CSS module classes rather than utility variables. - Some inline font sizes exist (e.g., `fontSize: 10` in DevToolbar). ### Pillar 5: Spacing (3/4) + - Relies cleanly on CSS modules. - Only a few inline spacing overrides (e.g., `marginTop: 6` in DevToolbar). ### Pillar 6: Experience Design (3/4) + - **PasswordGate.tsx**: Correctly disables the submit button and indicates processing state (`loading ? 'Verifying...' : 'Enter'`). - **MapView.tsx**: Handles API mapping errors gracefully (`error &&

{error}

`). --- ## Files Audited + - `app/api/auth/route.ts` - `app/api/health/route.ts` - `components/PasswordGate.tsx` diff --git a/SECURITY.md b/SECURITY.md index efe21f5..bb02f8f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,10 @@ Only the latest release on `main` is actively maintained. -| Version | Supported | -|---|---| -| `main` (latest) | āœ… | -| older commits | āŒ | +| Version | Supported | +| --------------- | --------- | +| `main` (latest) | āœ… | +| older commits | āŒ | ## Reporting a Vulnerability diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 4116472..73ad8f6 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -107,18 +107,26 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] { } /** - * Returns a PasswordGate element if the slug is protected and not yet - * authenticated, otherwise returns null. Centralises the duplicated - * cookie-check boilerplate. + * Returns a PasswordGate element if the key (slug or ID) is protected and + * not yet authenticated, otherwise returns null. */ -async function gateIfProtected(slug: string): Promise { - if (!isProtected(slug)) return null; +async function gateIfProtected( + key: string, + type: 'subpage' | 'album' = 'subpage', + titleOverride?: string, +): Promise { + if (!isProtected(key, type)) return null; const cookieStore = await cookies(); const getCookie = (name: string) => cookieStore.get(name)?.value; - if (isAuthenticated(slug, getCookie)) return null; - const subpageData = await immich.getSubpageAlbums(slug); - const title = subpageData?.subpage.name ?? slug; - return ; + if (isAuthenticated(key, getCookie, type)) return null; + + let title = titleOverride || key; + if (!titleOverride && type === 'subpage') { + const subpageData = await immich.getSubpageAlbums(key); + title = subpageData?.subpage.name ?? key; + } + + return ; } export default async function PathPage({ params }: PathPageProps) { @@ -162,6 +170,10 @@ export default async function PathPage({ params }: PathPageProps) { const images = toPhotoItems(album.assets, config.exifOnHover); + // Password gate for protected albums + const albumGate = await gateIfProtected(album.id, 'album', album.albumName); + if (albumGate) return albumGate; + return ( 100) { return NextResponse.json({ error: 'Invalid slug format or length' }, { status: 400 }); } @@ -37,11 +36,17 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 }); } - if (!isProtected(slug)) { - return NextResponse.json({ error: 'Album is not password-protected' }, { status: 400 }); + if (type !== 'subpage' && type !== 'album') { + return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } - const setCookie = authenticate(slug, password); + if (!isProtected(slug, type)) { + return NextResponse.json({ + error: `${type === 'subpage' ? 'Subpage' : 'Album'} is not password-protected`, + }, { status: 400 }); + } + + const setCookie = authenticate(slug, password, type); if (!setCookie) { return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); } diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 5df32ba..d2bdf09 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -68,7 +68,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return new NextResponse(null, { status: 304, headers: { - 'ETag': etag, + ETag: etag, 'Cache-Control': 'public, max-age=31536000, immutable', 'X-RateLimit-Remaining': String(remaining), }, @@ -87,7 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ : result.contentType, // Images are immutable once uploaded to Immich — cache aggressively 'Cache-Control': 'public, max-age=31536000, immutable', - 'ETag': etag, + ETag: etag, 'X-RateLimit-Remaining': String(remaining), }; diff --git a/components/DevToolbar.tsx b/components/DevToolbar.tsx index 75d5c88..9ec518f 100644 --- a/components/DevToolbar.tsx +++ b/components/DevToolbar.tsx @@ -93,7 +93,7 @@ export function DevToolbar() { // Update accent + fonts const accent = PRESET_ACCENTS[p] || 'var(--accent-studio, #e60012)'; setVar('--accent', accent); - // Note: since accent might be a CSS var, appending hex opacity won't work in all browsers if it's a var, + // Note: since accent might be a CSS var, appending hex opacity won't work in all browsers if it's a var, // but we provide it for backward compatibility where possible. Or use color-mix if supported. // For now we set it to same var or use a generic dim if needed. setVar('--accent-dim', `color-mix(in srgb, ${accent} 12%, transparent)`); diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 8c1ef9a..5db6859 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -11,9 +11,10 @@ import styles from './PasswordGate.module.css'; interface PasswordGateProps { slug: string; title: string; + type?: 'subpage' | 'album'; } -export default function PasswordGate({ slug, title }: PasswordGateProps) { +export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -27,7 +28,7 @@ export default function PasswordGate({ slug, title }: PasswordGateProps) { const res = await fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, password }), + body: JSON.stringify({ slug, password, type }), }); if (res.ok) { diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index 55865d8..3b555ae 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -23,7 +23,9 @@ subpages: password: "your-password" # Optional protection albums: - "album-uuid-5" - - "album-uuid-6" + - "album-uuid-6": + title: "Private Project #7" + password: "scrypt:..." # Recommended: secure hash (generated via logs) # ── Subpage with Sections ────────────────────────────────────────── # Use sections to group albums by location, theme, or any other logic. diff --git a/docs/gallery-config.md b/docs/gallery-config.md index 41a03a7..892464d 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -60,9 +60,12 @@ subpages: - 44444444-4444-4444-4444-444444444444 # Kyoto - name: Wedding – Smith # → /wedding-smith - password: clientpass123 # Optional: password-protected + password: clientpass123 # Optional: subpage-level protection albums: - 55555555-5555-5555-5555-555555555555 + - 66666666-6666-6666-6666-666666666666: + title: "Private Highlights" + password: "album-secret-123" # Optional: album-level protection ``` Alternatively, you can use the object notation (recommended): @@ -88,12 +91,15 @@ Each section can have: | `description` | string | āž– | Optional subline under the heading | | `albums` | list | āœ… | Album UUIDs (same format as regular albums) | -Within the `albums` list you can use a plain UUID or override the display title: +Within the `albums` list you can use a plain UUID, a simple title override, or an object with `title` and `password`: ```yaml albums: - 'album-uuid' # → uses the Immich album name - 'album-uuid': My Title # → displays "My Title" instead + - 'album-uuid': + title: My Private Title + password: "secure-password" # → adds a password gate to this specific album ``` **Full example:** diff --git a/lib/auth.ts b/lib/auth.ts index 9ef194a..b6026ec 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -48,13 +48,13 @@ function hmac(data: string): string { return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex'); } -function authToken(slug: string, passwordSecret: string): string { +function authToken(key: string, passwordSecret: string): string { // Use the hash/password as part of the HMAC for a secure, unique session token - return hmac(`${slug}:${passwordSecret}`); + return hmac(`${key}:${passwordSecret}`); } -function cookieName(slug: string): string { - return `lb_auth_${slug}`; +function cookieName(key: string, type: 'subpage' | 'album'): string { + return type === 'subpage' ? `lb_auth_${key}` : `lb_auth_album_${key}`; } /** @@ -65,26 +65,40 @@ export function findSubpageBySlug(slug: string): SubpageConfig | undefined { } /** - * Check if a slug corresponds to a password-protected subpage. + * Find the password secret for a given subpage slug or album ID. */ -export function isProtected(slug: string): boolean { - const sp = findSubpageBySlug(slug); - return !!sp?.password; +function findPassword(key: string, type: 'subpage' | 'album'): string | undefined { + const config = getConfig(); + if (type === 'subpage') { + return config.subpages.find((sp) => sp.slug === key)?.password; + } + return config.albumPasswords[key]; +} + +/** + * Check if a subpage slug or album ID is password-protected. + */ +export function isProtected(key: string, type: 'subpage' | 'album' = 'subpage'): boolean { + return !!findPassword(key, type); } /** * Validate a password attempt and return a Set-Cookie header value on success. * Returns null if the password is wrong. */ -export function authenticate(slug: string, password: string): string | null { - const sp = findSubpageBySlug(slug); - if (!sp?.password) return null; +export function authenticate( + key: string, + password: string, + type: 'subpage' | 'album' = 'subpage', +): string | null { + const storedPassword = findPassword(key, type); + if (!storedPassword) return null; let isValid = false; - if (isBcryptHash(sp.password)) { + if (isBcryptHash(storedPassword)) { console.error( - `\nāŒ SECURITY ERROR: Subpage "${slug}" is using an outdated bcrypt password hash.\n` + + `\nāŒ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" 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`, @@ -92,19 +106,19 @@ export function authenticate(slug: string, password: string): string | null { return null; } - if (sp.password.startsWith('scrypt:')) { - isValid = verifyScrypt(password, sp.password); + if (storedPassword.startsWith('scrypt:')) { + isValid = verifyScrypt(password, storedPassword); } else { // Plaintext fallback (deprecated) // Hash both to a fixed length before constant-time comparison to prevent timing and length attacks const attemptHash = crypto.createHash('sha256').update(password).digest(); - const storedHash = crypto.createHash('sha256').update(sp.password).digest(); + const storedHash = crypto.createHash('sha256').update(storedPassword).digest(); isValid = crypto.timingSafeEqual(attemptHash, storedHash); if (isValid) { - const recommendedHash = generateScryptHash(sp.password); + const recommendedHash = generateScryptHash(storedHash.toString('hex')); console.warn( - `\nāš ļø SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` + + `\nāš ļø SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` + ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`, ); } @@ -112,28 +126,29 @@ export function authenticate(slug: string, password: string): string | null { if (!isValid) return null; - const token = authToken(slug, sp.password); + const token = authToken(key, storedPassword); const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; - return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`; + return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`; } /** - * Check if the request has a valid auth cookie for the given slug. + * Check if the request has a valid auth cookie for the given subpage or album. * Works with the cookies() API from Next.js server components. */ export function isAuthenticated( - slug: string, + key: string, getCookie: (name: string) => string | undefined, + type: 'subpage' | 'album' = 'subpage', ): boolean { - const sp = findSubpageBySlug(slug); - if (!sp?.password) return true; // not protected + const storedPassword = findPassword(key, type); + if (!storedPassword) return true; // not protected - const cookie = getCookie(cookieName(slug)); + const cookie = getCookie(cookieName(key, type)); if (!cookie) return false; - const expected = authToken(slug, sp.password); + const expected = authToken(key, storedPassword); // Constant-time comparison to prevent timing attacks try { return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex')); diff --git a/lib/config/index.ts b/lib/config/index.ts index fe43ac2..271a638 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -90,6 +90,7 @@ export function getConfig(): AppConfig { map: false, transitions: false, albumOverrides: {}, + albumPasswords: {}, cacheTtl: env.CACHE_TTL * 1000, rateLimitRpm: env.RATE_LIMIT_RPM, needsSetup: true, @@ -100,14 +101,24 @@ export function getConfig(): AppConfig { const theme = resolveTheme(settings.theme); const albumOverrides: Record = {}; + const albumPasswords: Record = {}; - function processAlbumEntry(entry: string | Record, context: string): string { + function processAlbumEntry( + entry: string | Record, + context: string, + ): string { if (typeof entry === 'string') { return validateUuid(entry, context); } - const [uuid, name] = Object.entries(entry)[0]; + const [uuid, value] = Object.entries(entry)[0]; const validatedUuid = validateUuid(uuid, context); - albumOverrides[validatedUuid] = name; + + if (typeof value === 'string') { + albumOverrides[validatedUuid] = value; + } else { + if (value.title) albumOverrides[validatedUuid] = value.title; + if (value.password) albumPasswords[validatedUuid] = value.password; + } return validatedUuid; } @@ -254,6 +265,7 @@ export function getConfig(): AppConfig { map: settings.map === true, transitions: settings.transitions !== false, albumOverrides, + albumPasswords, cacheTtl: env.CACHE_TTL * 1000, rateLimitRpm: env.RATE_LIMIT_RPM, needsSetup: false, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 641e51b..f15a868 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -21,11 +21,11 @@ export interface SubpageObjectValue { subtitle?: string; password?: string; grid?: Partial; - albums?: Array>; + albums?: Array>; sections?: Array<{ title: string; description?: string; - albums: Array>; + albums: Array>; }>; } @@ -91,6 +91,7 @@ export interface AppConfig { map: boolean; transitions: boolean; albumOverrides: Record; + albumPasswords: Record; cacheTtl: number; rateLimitRpm: number; needsSetup?: boolean; @@ -105,11 +106,11 @@ export interface GalleryYaml { name: string; title?: string; subtitle?: string; - albums?: Array>; + albums?: Array>; sections?: Array<{ title: string; description?: string; - albums: Array>; + albums: Array>; }>; password?: string; grid?: {