feat: implement album-level password protection

This commit is contained in:
ralksta
2026-04-07 23:04:03 +02:00
parent e739fdb518
commit 17398dea39
16 changed files with 168 additions and 98 deletions

View File

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

View File

@@ -1,7 +1,7 @@
---
name: "\U0001F4A1 Feature request"
about: Suggest an idea or improvement
title: "[feat] "
title: '[feat] '
labels: enhancement
assignees: ''
---

View File

@@ -1,6 +1,7 @@
## Summary
<!-- What does this PR do? Reference the issue it closes if applicable. -->
Closes #
## Type of change

View File

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

View File

@@ -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 && <p>{error}</p>`).
---
## Files Audited
- `app/api/auth/route.ts`
- `app/api/health/route.ts`
- `components/PasswordGate.tsx`

View File

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

View File

@@ -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<React.ReactElement | null> {
if (!isProtected(slug)) return null;
async function gateIfProtected(
key: string,
type: 'subpage' | 'album' = 'subpage',
titleOverride?: string,
): Promise<React.ReactElement | null> {
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 <PasswordGate slug={slug} title={title} />;
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 <PasswordGate slug={key} title={title} type={type} />;
}
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 (
<AlbumDetailView
album={album}
@@ -197,6 +209,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 (
<AlbumDetailView
album={album}
@@ -241,6 +257,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 (
<AlbumDetailView
album={album}

View File

@@ -21,14 +21,13 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { slug, password } = body;
const { slug, password, type = 'subpage' } = body;
if (!slug || !password) {
return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 });
}
// 🛡️ SECURITY: Validate input types and limit length to prevent DoS attacks
// (e.g. from extremely long strings being passed to expensive crypto functions)
if (typeof slug !== 'string' || slug.length > 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 });
}

View File

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

View File

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

View File

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

View File

@@ -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:**

View File

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

View File

@@ -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<string, string> = {};
const albumPasswords: Record<string, string> = {};
function processAlbumEntry(entry: string | Record<string, string>, context: string): string {
function processAlbumEntry(
entry: string | Record<string, string | { title: string; password?: string }>,
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,

View File

@@ -21,11 +21,11 @@ export interface SubpageObjectValue {
subtitle?: string;
password?: string;
grid?: Partial<GridConfig>;
albums?: Array<string | Record<string, string>>;
albums?: Array<string | Record<string, string | { title: string; password?: string }>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<string | Record<string, string>>;
albums: Array<string | Record<string, string | { title: string; password?: string }>>;
}>;
}
@@ -91,6 +91,7 @@ export interface AppConfig {
map: boolean;
transitions: boolean;
albumOverrides: Record<string, string>;
albumPasswords: Record<string, string>;
cacheTtl: number;
rateLimitRpm: number;
needsSetup?: boolean;
@@ -105,11 +106,11 @@ export interface GalleryYaml {
name: string;
title?: string;
subtitle?: string;
albums?: Array<string | Record<string, string>>;
albums?: Array<string | Record<string, string | { title: string; password?: string }>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<string | Record<string, string>>;
albums: Array<string | Record<string, string | { title: string; password?: string }>>;
}>;
password?: string;
grid?: {