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?: {