feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
/**
|
|
|
|
|
* Admin authentication utilities.
|
|
|
|
|
* Uses HMAC-signed session tokens stored in HttpOnly cookies.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
import { env } from '../env';
|
2026-07-20 18:57:57 +02:00
|
|
|
import { resolveAuthSecret } from '../secret';
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
import { cookies } from 'next/headers';
|
|
|
|
|
|
|
|
|
|
const COOKIE_NAME = 'folio_admin_session';
|
|
|
|
|
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
|
|
security: stop leaking the admin password length through timing (#395)
verifyAdminPassword compared the raw strings, so timingSafeEqual could
only run once the lengths already matched. The early return on a length
mismatch is observable: an attacker who can time login attempts learns
how long ADMIN_PASSWORD is, which is exactly the information that makes
a brute-force search tractable. The dummy comparison that guarded the
mismatch path did not help — it ran on Buffer.alloc(attempt.length),
which is attacker-sized and leaves the branch timing intact anyway.
HMAC both sides to a fixed 32 bytes first, then compare. Every attempt
now takes the same path regardless of length. Keyed with AUTH_SECRET
rather than a bare SHA-256 so the intermediate values are useless
without the secret, matching how lib/auth.ts already compares subpage
passwords.
MAX_PASSWORD_LENGTH caps an attempt at 1024 characters, rejected in the
route before the body reaches the hashing path and again in the library
so no caller can bypass it. 1024 is far above any real passphrase — a
cap a user could actually hit would be a lockout, not a fix.
Supersedes #390 and #388, which fixed the same comparison with an
unkeyed digest. The .jules/sentinel.md note the original branch carried
is left out: .jules/ is gitignored since cf2e8e6.
Adds 3 tests: a wrong password of exactly the right length, an
oversized attempt, and that a long configured password still logs in.
Verified: 210 unit tests passing (207 before), vitest exit 0, tsc 0
errors, production build exit 0, prettier clean.
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:13:32 +02:00
|
|
|
/**
|
|
|
|
|
* Longest password attempt the login endpoint will look at.
|
|
|
|
|
*
|
|
|
|
|
* Generous enough that no real passphrase hits it — the point is only to keep
|
|
|
|
|
* an unbounded request body out of the hashing path, not to constrain what
|
|
|
|
|
* users may choose.
|
|
|
|
|
*/
|
|
|
|
|
export const MAX_PASSWORD_LENGTH = 1024;
|
|
|
|
|
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
function getSigningKey(): Buffer {
|
2026-07-20 18:57:57 +02:00
|
|
|
// Bind the key to ADMIN_PASSWORD as well, so rotating the password
|
|
|
|
|
// immediately invalidates every outstanding session token.
|
|
|
|
|
const secret = resolveAuthSecret();
|
|
|
|
|
return crypto
|
|
|
|
|
.createHash('sha256')
|
|
|
|
|
.update(`admin:${secret}:${env.ADMIN_PASSWORD ?? ''}`)
|
|
|
|
|
.digest();
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Create a signed session token. */
|
|
|
|
|
export function createAdminToken(): string {
|
|
|
|
|
const payload = {
|
|
|
|
|
role: 'admin',
|
|
|
|
|
iat: Date.now(),
|
|
|
|
|
exp: Date.now() + SESSION_DURATION_MS,
|
|
|
|
|
};
|
|
|
|
|
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
|
|
|
const key = getSigningKey();
|
|
|
|
|
const sig = crypto.createHmac('sha256', key).update(data).digest('base64url');
|
|
|
|
|
return `${data}.${sig}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Verify a session token. Returns true if valid. */
|
|
|
|
|
export function verifyAdminToken(token: string): boolean {
|
|
|
|
|
const parts = token.split('.');
|
|
|
|
|
if (parts.length !== 2) return false;
|
|
|
|
|
|
|
|
|
|
const [data, sig] = parts;
|
|
|
|
|
const key = getSigningKey();
|
|
|
|
|
const expectedSig = crypto.createHmac('sha256', key).update(data).digest('base64url');
|
|
|
|
|
|
2026-07-20 18:57:57 +02:00
|
|
|
// timingSafeEqual throws on length mismatch — guard first so a malformed
|
|
|
|
|
// cookie yields a clean 401 instead of an unhandled RangeError (500).
|
|
|
|
|
const sigBuf = Buffer.from(sig);
|
|
|
|
|
const expectedBuf = Buffer.from(expectedSig);
|
|
|
|
|
if (sigBuf.length !== expectedBuf.length) return false;
|
|
|
|
|
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) {
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
|
|
|
|
if (payload.role !== 'admin') return false;
|
|
|
|
|
if (typeof payload.exp !== 'number' || payload.exp < Date.now()) return false;
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
security: stop leaking the admin password length through timing (#395)
verifyAdminPassword compared the raw strings, so timingSafeEqual could
only run once the lengths already matched. The early return on a length
mismatch is observable: an attacker who can time login attempts learns
how long ADMIN_PASSWORD is, which is exactly the information that makes
a brute-force search tractable. The dummy comparison that guarded the
mismatch path did not help — it ran on Buffer.alloc(attempt.length),
which is attacker-sized and leaves the branch timing intact anyway.
HMAC both sides to a fixed 32 bytes first, then compare. Every attempt
now takes the same path regardless of length. Keyed with AUTH_SECRET
rather than a bare SHA-256 so the intermediate values are useless
without the secret, matching how lib/auth.ts already compares subpage
passwords.
MAX_PASSWORD_LENGTH caps an attempt at 1024 characters, rejected in the
route before the body reaches the hashing path and again in the library
so no caller can bypass it. 1024 is far above any real passphrase — a
cap a user could actually hit would be a lockout, not a fix.
Supersedes #390 and #388, which fixed the same comparison with an
unkeyed digest. The .jules/sentinel.md note the original branch carried
is left out: .jules/ is gitignored since cf2e8e6.
Adds 3 tests: a wrong password of exactly the right length, an
oversized attempt, and that a long configured password still logs in.
Verified: 210 unit tests passing (207 before), vitest exit 0, tsc 0
errors, production build exit 0, prettier clean.
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:13:32 +02:00
|
|
|
/**
|
|
|
|
|
* Verify admin password.
|
|
|
|
|
*
|
|
|
|
|
* Both sides are HMAC'd to a fixed 32 bytes before the constant-time compare.
|
|
|
|
|
* Comparing the raw strings meant `timingSafeEqual` could only run when the
|
|
|
|
|
* lengths already matched, so the early return on a length mismatch leaked how
|
|
|
|
|
* long ADMIN_PASSWORD is. Hashing first makes every attempt take the same path.
|
|
|
|
|
*
|
|
|
|
|
* Keyed with AUTH_SECRET rather than a bare digest, matching how `lib/auth.ts`
|
|
|
|
|
* compares subpage passwords: the intermediate values are then useless to
|
|
|
|
|
* anyone who cannot also read the secret.
|
|
|
|
|
*/
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
export function verifyAdminPassword(password: string): boolean {
|
|
|
|
|
const adminPw = env.ADMIN_PASSWORD;
|
|
|
|
|
if (!adminPw) return false;
|
security: stop leaking the admin password length through timing (#395)
verifyAdminPassword compared the raw strings, so timingSafeEqual could
only run once the lengths already matched. The early return on a length
mismatch is observable: an attacker who can time login attempts learns
how long ADMIN_PASSWORD is, which is exactly the information that makes
a brute-force search tractable. The dummy comparison that guarded the
mismatch path did not help — it ran on Buffer.alloc(attempt.length),
which is attacker-sized and leaves the branch timing intact anyway.
HMAC both sides to a fixed 32 bytes first, then compare. Every attempt
now takes the same path regardless of length. Keyed with AUTH_SECRET
rather than a bare SHA-256 so the intermediate values are useless
without the secret, matching how lib/auth.ts already compares subpage
passwords.
MAX_PASSWORD_LENGTH caps an attempt at 1024 characters, rejected in the
route before the body reaches the hashing path and again in the library
so no caller can bypass it. 1024 is far above any real passphrase — a
cap a user could actually hit would be a lockout, not a fix.
Supersedes #390 and #388, which fixed the same comparison with an
unkeyed digest. The .jules/sentinel.md note the original branch carried
is left out: .jules/ is gitignored since cf2e8e6.
Adds 3 tests: a wrong password of exactly the right length, an
oversized attempt, and that a long configured password still logs in.
Verified: 210 unit tests passing (207 before), vitest exit 0, tsc 0
errors, production build exit 0, prettier clean.
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:13:32 +02:00
|
|
|
if (password.length > MAX_PASSWORD_LENGTH) return false;
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
|
security: stop leaking the admin password length through timing (#395)
verifyAdminPassword compared the raw strings, so timingSafeEqual could
only run once the lengths already matched. The early return on a length
mismatch is observable: an attacker who can time login attempts learns
how long ADMIN_PASSWORD is, which is exactly the information that makes
a brute-force search tractable. The dummy comparison that guarded the
mismatch path did not help — it ran on Buffer.alloc(attempt.length),
which is attacker-sized and leaves the branch timing intact anyway.
HMAC both sides to a fixed 32 bytes first, then compare. Every attempt
now takes the same path regardless of length. Keyed with AUTH_SECRET
rather than a bare SHA-256 so the intermediate values are useless
without the secret, matching how lib/auth.ts already compares subpage
passwords.
MAX_PASSWORD_LENGTH caps an attempt at 1024 characters, rejected in the
route before the body reaches the hashing path and again in the library
so no caller can bypass it. 1024 is far above any real passphrase — a
cap a user could actually hit would be a lockout, not a fix.
Supersedes #390 and #388, which fixed the same comparison with an
unkeyed digest. The .jules/sentinel.md note the original branch carried
is left out: .jules/ is gitignored since cf2e8e6.
Adds 3 tests: a wrong password of exactly the right length, an
oversized attempt, and that a long configured password still logs in.
Verified: 210 unit tests passing (207 before), vitest exit 0, tsc 0
errors, production build exit 0, prettier clean.
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:13:32 +02:00
|
|
|
const secret = resolveAuthSecret();
|
|
|
|
|
const attemptHash = crypto.createHmac('sha256', secret).update(password).digest();
|
|
|
|
|
const expectedHash = crypto.createHmac('sha256', secret).update(adminPw).digest();
|
|
|
|
|
|
|
|
|
|
return crypto.timingSafeEqual(attemptHash, expectedHash);
|
feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
2026-05-29 17:04:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Check if the current request has a valid admin session. */
|
|
|
|
|
export async function isAdminAuthenticated(): Promise<boolean> {
|
|
|
|
|
const cookieStore = await cookies();
|
|
|
|
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
|
|
|
if (!token) return false;
|
|
|
|
|
return verifyAdminToken(token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Check if admin panel is enabled (password is set). */
|
|
|
|
|
export function isAdminEnabled(): boolean {
|
|
|
|
|
return !!env.ADMIN_PASSWORD;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { COOKIE_NAME, SESSION_DURATION_MS };
|