feat: implement album-level password protection
This commit is contained in:
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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)
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: "\U0001F4A1 Feature request"
|
||||
about: Suggest an idea or improvement
|
||||
title: "[feat] "
|
||||
title: '[feat] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Reference the issue it closes if applicable. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of change
|
||||
|
||||
44
.github/dependabot.yml
vendored
44
.github/dependabot.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:**
|
||||
|
||||
67
lib/auth.ts
67
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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user