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" name: "\U0001F41B Bug report"
about: Something isn't working as expected about: Something isn't working as expected
title: "[bug] " title: '[bug] '
labels: bug labels: bug
assignees: '' assignees: ''
--- ---
@@ -27,7 +27,7 @@ What actually happened. Include screenshots if relevant.
## Environment ## Environment
| Field | Value | | Field | Value |
|---|---| | -------------------- | ------------------------- |
| immich-folio version | vX.X.X / commit SHA | | immich-folio version | vX.X.X / commit SHA |
| Immich version | | | Immich version | |
| Deployment | Docker / manual | | Deployment | Docker / manual |

View File

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

View File

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

View File

@@ -2,41 +2,41 @@ version: 2
updates: updates:
# npm dependencies # npm dependencies
- package-ecosystem: "npm" - package-ecosystem: 'npm'
directory: "/" directory: '/'
schedule: schedule:
interval: "weekly" interval: 'weekly'
day: "monday" day: 'monday'
time: "09:00" time: '09:00'
timezone: "Europe/Berlin" timezone: 'Europe/Berlin'
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - 'dependencies'
groups: groups:
# Batch minor/patch Next.js updates together # Batch minor/patch Next.js updates together
nextjs: nextjs:
patterns: patterns:
- "next" - 'next'
- "eslint-config-next" - 'eslint-config-next'
# Batch React updates # Batch React updates
react: react:
patterns: patterns:
- "react" - 'react'
- "react-dom" - 'react-dom'
- "@types/react*" - '@types/react*'
ignore: ignore:
# Don't auto-update major versions (review manually) # Don't auto-update major versions (review manually)
- dependency-name: "*" - dependency-name: '*'
update-types: ["version-update:semver-major"] update-types: ['version-update:semver-major']
# GitHub Actions # GitHub Actions
- package-ecosystem: "github-actions" - package-ecosystem: 'github-actions'
directory: "/" directory: '/'
schedule: schedule:
interval: "weekly" interval: 'weekly'
day: "monday" day: 'monday'
time: "09:00" time: '09:00'
timezone: "Europe/Berlin" timezone: 'Europe/Berlin'
labels: labels:
- "dependencies" - 'dependencies'
- "github-actions" - 'github-actions'

View File

@@ -9,7 +9,7 @@
## Pillar Scores ## Pillar Scores
| Pillar | Score | Key Finding | | Pillar | Score | Key Finding |
|--------|-------|-------------| | -------------------- | ----- | ------------------------------------------------------------------------ |
| 1. Copywriting | 3/4 | Error states use generic 'Something went wrong'. | | 1. Copywriting | 3/4 | Error states use generic 'Something went wrong'. |
| 2. Visuals | 3/4 | Standard component structures, but icon accessibility could be improved. | | 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`. | | 3. Color | 2/4 | Heavy use of hardcoded colors in `DevToolbar.tsx`. |
@@ -32,30 +32,37 @@
## Detailed Findings ## Detailed Findings
### Pillar 1: Copywriting (3/4) ### 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. - **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) ### 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. - 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) ### 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. - **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`). - **Lightbox / SetupScreen**: Minimal hardcoded values used for fallback (`#000`, `#ffffff`).
### Pillar 4: Typography (3/4) ### Pillar 4: Typography (3/4)
- Exclusively uses CSS module classes rather than utility variables. - Exclusively uses CSS module classes rather than utility variables.
- Some inline font sizes exist (e.g., `fontSize: 10` in DevToolbar). - Some inline font sizes exist (e.g., `fontSize: 10` in DevToolbar).
### Pillar 5: Spacing (3/4) ### Pillar 5: Spacing (3/4)
- Relies cleanly on CSS modules. - Relies cleanly on CSS modules.
- Only a few inline spacing overrides (e.g., `marginTop: 6` in DevToolbar). - Only a few inline spacing overrides (e.g., `marginTop: 6` in DevToolbar).
### Pillar 6: Experience Design (3/4) ### Pillar 6: Experience Design (3/4)
- **PasswordGate.tsx**: Correctly disables the submit button and indicates processing state (`loading ? 'Verifying...' : 'Enter'`). - **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>`). - **MapView.tsx**: Handles API mapping errors gracefully (`error && <p>{error}</p>`).
--- ---
## Files Audited ## Files Audited
- `app/api/auth/route.ts` - `app/api/auth/route.ts`
- `app/api/health/route.ts` - `app/api/health/route.ts`
- `components/PasswordGate.tsx` - `components/PasswordGate.tsx`

View File

@@ -5,7 +5,7 @@
Only the latest release on `main` is actively maintained. Only the latest release on `main` is actively maintained.
| Version | Supported | | Version | Supported |
|---|---| | --------------- | --------- |
| `main` (latest) | ✅ | | `main` (latest) | ✅ |
| older commits | ❌ | | older commits | ❌ |

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 * Returns a PasswordGate element if the key (slug or ID) is protected and
* authenticated, otherwise returns null. Centralises the duplicated * not yet authenticated, otherwise returns null.
* cookie-check boilerplate.
*/ */
async function gateIfProtected(slug: string): Promise<React.ReactElement | null> { async function gateIfProtected(
if (!isProtected(slug)) return null; key: string,
type: 'subpage' | 'album' = 'subpage',
titleOverride?: string,
): Promise<React.ReactElement | null> {
if (!isProtected(key, type)) return null;
const cookieStore = await cookies(); const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value; const getCookie = (name: string) => cookieStore.get(name)?.value;
if (isAuthenticated(slug, getCookie)) return null; if (isAuthenticated(key, getCookie, type)) return null;
const subpageData = await immich.getSubpageAlbums(slug);
const title = subpageData?.subpage.name ?? slug; let title = titleOverride || key;
return <PasswordGate slug={slug} title={title} />; 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) { 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); 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 ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
@@ -197,6 +209,10 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover); 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 ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
@@ -241,6 +257,10 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover); 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 ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}

View File

@@ -21,14 +21,13 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { slug, password } = body; const { slug, password, type = 'subpage' } = body;
if (!slug || !password) { if (!slug || !password) {
return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 }); return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 });
} }
// 🛡️ SECURITY: Validate input types and limit length to prevent DoS attacks // 🛡️ 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) { if (typeof slug !== 'string' || slug.length > 100) {
return NextResponse.json({ error: 'Invalid slug format or length' }, { status: 400 }); 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 }); return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
} }
if (!isProtected(slug)) { if (type !== 'subpage' && type !== 'album') {
return NextResponse.json({ error: 'Album is not password-protected' }, { status: 400 }); 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) { if (!setCookie) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); 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, { return new NextResponse(null, {
status: 304, status: 304,
headers: { headers: {
'ETag': etag, ETag: etag,
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'public, max-age=31536000, immutable',
'X-RateLimit-Remaining': String(remaining), 'X-RateLimit-Remaining': String(remaining),
}, },
@@ -87,7 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
: result.contentType, : result.contentType,
// Images are immutable once uploaded to Immich — cache aggressively // Images are immutable once uploaded to Immich — cache aggressively
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': etag, ETag: etag,
'X-RateLimit-Remaining': String(remaining), 'X-RateLimit-Remaining': String(remaining),
}; };

View File

@@ -11,9 +11,10 @@ import styles from './PasswordGate.module.css';
interface PasswordGateProps { interface PasswordGateProps {
slug: string; slug: string;
title: 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 [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,7 +28,7 @@ export default function PasswordGate({ slug, title }: PasswordGateProps) {
const res = await fetch('/api/auth', { const res = await fetch('/api/auth', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, password }), body: JSON.stringify({ slug, password, type }),
}); });
if (res.ok) { if (res.ok) {

View File

@@ -23,7 +23,9 @@ subpages:
password: "your-password" # Optional protection password: "your-password" # Optional protection
albums: albums:
- "album-uuid-5" - "album-uuid-5"
- "album-uuid-6" - "album-uuid-6":
title: "Private Project #7"
password: "scrypt:..." # Recommended: secure hash (generated via logs)
# ── Subpage with Sections ────────────────────────────────────────── # ── Subpage with Sections ──────────────────────────────────────────
# Use sections to group albums by location, theme, or any other logic. # 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 - 44444444-4444-4444-4444-444444444444 # Kyoto
- name: Wedding – Smith # → /wedding-smith - name: Wedding – Smith # → /wedding-smith
password: clientpass123 # Optional: password-protected password: clientpass123 # Optional: subpage-level protection
albums: albums:
- 55555555-5555-5555-5555-555555555555 - 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): Alternatively, you can use the object notation (recommended):
@@ -88,12 +91,15 @@ Each section can have:
| `description` | string | ➖ | Optional subline under the heading | | `description` | string | ➖ | Optional subline under the heading |
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) | | `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 ```yaml
albums: albums:
- 'album-uuid' # → uses the Immich album name - 'album-uuid' # → uses the Immich album name
- 'album-uuid': My Title # → displays "My Title" instead - '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:** **Full example:**

View File

@@ -48,13 +48,13 @@ function hmac(data: string): string {
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex'); 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 // 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 { function cookieName(key: string, type: 'subpage' | 'album'): string {
return `lb_auth_${slug}`; 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 { function findPassword(key: string, type: 'subpage' | 'album'): string | undefined {
const sp = findSubpageBySlug(slug); const config = getConfig();
return !!sp?.password; 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. * Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong. * Returns null if the password is wrong.
*/ */
export function authenticate(slug: string, password: string): string | null { export function authenticate(
const sp = findSubpageBySlug(slug); key: string,
if (!sp?.password) return null; password: string,
type: 'subpage' | 'album' = 'subpage',
): string | null {
const storedPassword = findPassword(key, type);
if (!storedPassword) return null;
let isValid = false; let isValid = false;
if (isBcryptHash(sp.password)) { if (isBcryptHash(storedPassword)) {
console.error( 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` + ` Bcrypt dependency has been removed to reduce bundle size.\n` +
` Please switch temporarily to plaintext in your gallery.yaml, log in again\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`, ` 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; return null;
} }
if (sp.password.startsWith('scrypt:')) { if (storedPassword.startsWith('scrypt:')) {
isValid = verifyScrypt(password, sp.password); isValid = verifyScrypt(password, storedPassword);
} else { } else {
// Plaintext fallback (deprecated) // Plaintext fallback (deprecated)
// Hash both to a fixed length before constant-time comparison to prevent timing and length attacks // 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 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); isValid = crypto.timingSafeEqual(attemptHash, storedHash);
if (isValid) { if (isValid) {
const recommendedHash = generateScryptHash(sp.password); const recommendedHash = generateScryptHash(storedHash.toString('hex'));
console.warn( 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`, ` 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; if (!isValid) return null;
const token = authToken(slug, sp.password); const token = authToken(key, storedPassword);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; 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. * Works with the cookies() API from Next.js server components.
*/ */
export function isAuthenticated( export function isAuthenticated(
slug: string, key: string,
getCookie: (name: string) => string | undefined, getCookie: (name: string) => string | undefined,
type: 'subpage' | 'album' = 'subpage',
): boolean { ): boolean {
const sp = findSubpageBySlug(slug); const storedPassword = findPassword(key, type);
if (!sp?.password) return true; // not protected if (!storedPassword) return true; // not protected
const cookie = getCookie(cookieName(slug)); const cookie = getCookie(cookieName(key, type));
if (!cookie) return false; if (!cookie) return false;
const expected = authToken(slug, sp.password); const expected = authToken(key, storedPassword);
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks
try { try {
return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex')); return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));

View File

@@ -90,6 +90,7 @@ export function getConfig(): AppConfig {
map: false, map: false,
transitions: false, transitions: false,
albumOverrides: {}, albumOverrides: {},
albumPasswords: {},
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
needsSetup: true, needsSetup: true,
@@ -100,14 +101,24 @@ export function getConfig(): AppConfig {
const theme = resolveTheme(settings.theme); const theme = resolveTheme(settings.theme);
const albumOverrides: Record<string, string> = {}; 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') { if (typeof entry === 'string') {
return validateUuid(entry, context); return validateUuid(entry, context);
} }
const [uuid, name] = Object.entries(entry)[0]; const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context); 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; return validatedUuid;
} }
@@ -254,6 +265,7 @@ export function getConfig(): AppConfig {
map: settings.map === true, map: settings.map === true,
transitions: settings.transitions !== false, transitions: settings.transitions !== false,
albumOverrides, albumOverrides,
albumPasswords,
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
needsSetup: false, needsSetup: false,

View File

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