feat: implement album-level password protection
This commit is contained in:
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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 |
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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: ''
|
||||||
---
|
---
|
||||||
|
|||||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
|
||||||
|
|||||||
44
.github/dependabot.yml
vendored
44
.github/dependabot.yml
vendored
@@ -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'
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 | ❌ |
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
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');
|
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'));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user