release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -1,5 +1,5 @@
/**
* Album authentication helpers.
* Password gating for subpages, albums, journal entries and the site as a whole.
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
*
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
@@ -12,6 +12,42 @@ import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24;
/** What a password can protect. */
export type ProtectedType = 'subpage' | 'album' | 'journal' | 'site';
/**
* Key used for the site-wide gate. There is only ever one, so it is a constant
* rather than a slug — but it still flows through the same token, cookie and
* verification path as every other protected thing.
*
* This is the *public* identifier, the one the gate form posts to `/api/auth`.
* The value that goes into the HMAC is `SITE_TOKEN_KEY`, see below.
*/
export const SITE_AUTH_KEY = 'site';
/**
* Domain separator for the site token.
*
* A subpage may legitimately be slugged `site`, and a subpage's key is its
* slug — so signing the site token as `site` would sign both gates with the
* same key. With matching passwords the two tokens are then byte-identical and
* either gate accepts the other's cookie. `slugify()` cannot emit an
* underscore, so no slug can collide with this.
*/
const SITE_TOKEN_KEY = '__site__';
/** The key that actually gets signed: only the site gate is remapped. */
function tokenKey(key: string, type: ProtectedType): string {
return type === 'site' ? SITE_TOKEN_KEY : key;
}
const TYPE_LABELS: Record<ProtectedType, string> = {
subpage: 'Subpage',
album: 'Album',
journal: 'Journal entry',
site: 'Site',
};
/**
* Check if a string looks like a legacy bcrypt hash.
*/
@@ -40,12 +76,22 @@ import nodeFs from 'fs';
import { parseFrontmatter } from './journal';
import { resolveJournalFilePath } from './admin/journal-service';
function cookieName(key: string, type: 'subpage' | 'album' | 'journal'): string {
function cookieName(key: string, type: ProtectedType): string {
if (type === 'subpage') return `lb_auth_${key}`;
if (type === 'journal') return `lb_auth_journal_${key}`;
if (type === 'site') return SITE_AUTH_COOKIE;
return `lb_auth_album_${key}`;
}
/**
* Cookie carrying the site-wide session.
*
* Deliberately *not* `lb_auth_site`: subpage cookies are `lb_auth_<slug>`, so a
* subpage slugged `site` would write to the site gate's cookie and log the
* visitor out of the site on every subpage unlock.
*/
export const SITE_AUTH_COOKIE = 'lb_site_auth';
/**
* Find the SubpageConfig for a given slug.
*/
@@ -56,8 +102,11 @@ export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
/**
* Find the password secret for a given subpage slug, album ID, or journal entry.
*/
function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): string | undefined {
function findPassword(key: string, type: ProtectedType): string | undefined {
const config = getConfig();
if (type === 'site') {
return config.sitePassword || undefined;
}
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password;
}
@@ -80,10 +129,7 @@ function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): strin
/**
* Check if a subpage slug, album ID, or journal entry is password-protected.
*/
export function isProtected(
key: string,
type: 'subpage' | 'album' | 'journal' = 'subpage',
): boolean {
export function isProtected(key: string, type: ProtectedType = 'subpage'): boolean {
return !!findPassword(key, type);
}
@@ -94,7 +140,7 @@ export function isProtected(
export async function authenticate(
key: string,
password: string,
type: 'subpage' | 'album' | 'journal' = 'subpage',
type: ProtectedType = 'subpage',
): Promise<string | null> {
const storedPassword = findPassword(key, type);
if (!storedPassword) return null;
@@ -103,7 +149,7 @@ export async function authenticate(
if (isBcryptHash(storedPassword)) {
console.error(
`\n❌ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using an outdated bcrypt password hash.\n` +
`\n❌ SECURITY ERROR: ${TYPE_LABELS[type]} "${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`,
@@ -129,7 +175,7 @@ export async function authenticate(
if (isValid) {
const recommendedHash = await generateScryptHash(storedPassword);
console.warn(
`\n⚠️ SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` +
`\n⚠️ SECURITY WARNING: ${TYPE_LABELS[type]} "${key}" is using a plaintext password${type === 'site' ? ' in settings.yaml' : ' in gallery.yaml'}.\n` +
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
);
}
@@ -138,7 +184,7 @@ export async function authenticate(
if (!isValid) return null;
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const token = authToken(key, storedPassword, Date.now() + maxAge * 1000);
const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
@@ -151,7 +197,7 @@ export async function authenticate(
export function isAuthenticated(
key: string,
getCookie: (name: string) => string | undefined,
type: 'subpage' | 'album' | 'journal' = 'subpage',
type: ProtectedType = 'subpage',
): boolean {
const storedPassword = findPassword(key, type);
if (!storedPassword) return true; // not protected
@@ -167,7 +213,7 @@ export function isAuthenticated(
const expiresAt = Number(cookie.slice(0, sep));
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
const expected = authToken(key, storedPassword, expiresAt);
const expected = authToken(tokenKey(key, type), storedPassword, expiresAt);
// Constant-time comparison to prevent timing attacks
try {
const a = Buffer.from(cookie, 'utf8');
@@ -178,3 +224,55 @@ export function isAuthenticated(
return false;
}
}
/* ── Site-wide gate ─────────────────────────────────────────────
*
* Opt-in: with no `sitePassword` configured, `findPassword` returns undefined
* and every check below answers "unlocked", so an open portfolio pays nothing
* for the feature existing.
*/
/** True when a site-wide password is configured at all. */
export function isSiteLocked(): boolean {
return isProtected(SITE_AUTH_KEY, 'site');
}
/** Whether the caller may be served the public site. */
export function isSiteUnlocked(getCookie: (name: string) => string | undefined): boolean {
return isAuthenticated(SITE_AUTH_KEY, getCookie, 'site');
}
/** Read one cookie out of a raw `Cookie:` header. */
function readCookieHeader(header: string, name: string): string | undefined {
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() !== name) continue;
try {
return decodeURIComponent(part.slice(eq + 1).trim());
} catch {
return part.slice(eq + 1).trim();
}
}
return undefined;
}
/**
* Route-handler guard. Returns a 401 to send back, or null to carry on.
*
* Route handlers do not go through the root layout, so the page-level gate
* does not cover them: without this, a locked site would still stream every
* photo to anyone holding an asset URL.
*/
export function siteLockResponse(request: Request): Response | null {
const header = request.headers.get('cookie') ?? '';
if (isSiteUnlocked((name) => readCookieHeader(header, name))) return null;
return Response.json(
{ error: 'Site is password-protected' },
// WWW-Authenticate is deliberately omitted: the credential is a form on
// the site, not HTTP auth, and the header would make browsers pop their
// own dialog for an image request.
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
);
}