release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
@@ -147,6 +147,24 @@ export function checkProxyHops(
|
||||
};
|
||||
}
|
||||
|
||||
// configuredHops is between 0 and provenHops (both handled above), so the
|
||||
// position read is still inside the chain rather than at its start: every
|
||||
// request is read from a proxy's own address, not the client behind it.
|
||||
// Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
|
||||
// to 1 reads the Cloudflare edge address, and every visitor behind that
|
||||
// edge shares one rate-limit bucket — this used to report "matches the
|
||||
// observed chain".
|
||||
if (configuredHops < provenHops) {
|
||||
return {
|
||||
id: 'proxy-hops',
|
||||
level: 'warn',
|
||||
title: `TRUSTED_PROXY_HOPS is ${configuredHops}, but a chain of ${provenHops} proxy hops was seen`,
|
||||
detail:
|
||||
'The client IP is read from a position still inside the proxy chain, not the real client — ' +
|
||||
`every visitor behind that hop can share one rate-limit bucket. ${provenHops} is what this request suggests.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'proxy-hops',
|
||||
level: 'ok',
|
||||
@@ -244,6 +262,77 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
|
||||
};
|
||||
}
|
||||
|
||||
/** One set of albums that will all be reachable under the same URL prefix. */
|
||||
export interface AlbumSlugGroup {
|
||||
/** e.g. "gallery.yaml albums" or `subpage "Trips"` */
|
||||
context: string;
|
||||
albumIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Album slug collisions using each album's *resolved* name — the title
|
||||
* override if one is set, otherwise whatever Immich calls it. deriveGallery
|
||||
* can only see the override half of that (#632); this is the other half,
|
||||
* checked against the live album list the doctor already has.
|
||||
*
|
||||
* `slugOf` is a parameter rather than an import so this file keeps the zero
|
||||
* dependencies it has had since #491 — the whole reason `scripts/doctor.mts`
|
||||
* can run this module directly through Node's native TypeScript stripping,
|
||||
* with no bundler and no `lib/config` (which pulls in `fs`).
|
||||
*/
|
||||
export function checkAlbumSlugCollisions(
|
||||
groups: AlbumSlugGroup[],
|
||||
overrides: Record<string, string>,
|
||||
known: AlbumRef[],
|
||||
slugOf: (name: string) => string,
|
||||
): DoctorFinding {
|
||||
const byId = new Map(known.map((a) => [a.id, a]));
|
||||
const collisions: string[] = [];
|
||||
const collidingIds: string[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const named = group.albumIds
|
||||
.map((id) => ({ id, name: overrides[id] ?? byId.get(id)?.albumName }))
|
||||
.filter((a): a is { id: string; name: string } => !!a.name);
|
||||
|
||||
const bySlug = new Map<string, typeof named>();
|
||||
for (const album of named) {
|
||||
const slug = slugOf(album.name);
|
||||
if (!slug) continue; // Falls back to the id elsewhere, which is unique.
|
||||
const list = bySlug.get(slug) ?? [];
|
||||
list.push(album);
|
||||
bySlug.set(slug, list);
|
||||
}
|
||||
|
||||
for (const [slug, collided] of bySlug) {
|
||||
if (collided.length < 2) continue;
|
||||
collisions.push(
|
||||
`${group.context}: "${collided.map((a) => a.name).join('" and "')}" all resolve to /${slug}`,
|
||||
);
|
||||
collidingIds.push(...collided.map((a) => a.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (collisions.length) {
|
||||
return {
|
||||
id: 'album-slugs',
|
||||
level: 'error',
|
||||
title: `${collisions.length} album slug ${collisions.length === 1 ? 'collision' : 'collisions'} found`,
|
||||
detail:
|
||||
'Every album after the first in a collision is unreachable at its own URL, and its ' +
|
||||
`settings resolve from the one before it. ${collisions.join('; ')}`,
|
||||
albumIds: collidingIds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'album-slugs',
|
||||
level: 'ok',
|
||||
title: 'No album slug collisions',
|
||||
detail: 'Every album reachable under the same URL prefix has a distinct slug.',
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordRef {
|
||||
/** Where it lives, for the report: "Album japan-2024", "Site password". */
|
||||
label: string;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
calculateReadingTime,
|
||||
@@ -19,7 +20,6 @@ const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
|
||||
const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
|
||||
const BACKUP_DIR = path.join(JOURNAL_DIR, '.backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
let tmpCounter = 0;
|
||||
|
||||
/**
|
||||
* Names this service writes into .backups/, and the only ones it restores from.
|
||||
@@ -127,14 +127,7 @@ export async function restoreJournalBackup(backupFilename: string): Promise<stri
|
||||
}
|
||||
|
||||
await fs.mkdir(JOURNAL_DIR, { recursive: true });
|
||||
const tmpPath = `${target}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.rename(tmpPath, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(target, content);
|
||||
|
||||
console.log(`[Journal] 🔄 Restored ${filename} from ${backupFilename}`);
|
||||
return slug;
|
||||
@@ -280,23 +273,42 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
|
||||
throw new Error(`Invalid journal slug: "${slug}"`);
|
||||
}
|
||||
|
||||
// Create rolling backup if file already exists
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to looked exactly like a
|
||||
// brand-new entry: the save went ahead with no snapshot taken (#630). Only
|
||||
// ENOENT means there is nothing to back up; anything else aborts the save
|
||||
// before it overwrites the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await snapshotEntry(filePath, filename);
|
||||
await pruneEntryBackups(filename);
|
||||
} catch {
|
||||
// New file, no backup needed
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
// Atomic write via unique temp file
|
||||
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, rawMarkdown, 'utf8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
if (fileExists) {
|
||||
await snapshotEntry(filePath, filename);
|
||||
await pruneEntryBackups(filename);
|
||||
}
|
||||
|
||||
await atomicWrite(filePath, rawMarkdown);
|
||||
|
||||
// An entry that started in content/essays/ now has a copy in both
|
||||
// directories; resolveJournalFilePath would keep favouring this new one,
|
||||
// but deleteJournalEntry used to only ever see one path at a time and could
|
||||
// leave the legacy copy behind to resurface on the next listing (#631).
|
||||
// Retiring it here, once the new content is safely on disk, means delete
|
||||
// never has to reconcile two files for one slug.
|
||||
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
|
||||
if (legacyPath) {
|
||||
try {
|
||||
await fs.access(legacyPath);
|
||||
await snapshotEntry(legacyPath, filename);
|
||||
await fs.unlink(legacyPath);
|
||||
console.log(`[Journal] 🧹 Retired legacy copy of ${filename} in content/essays/`);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Journal] ✅ Saved ${filename}`);
|
||||
@@ -316,22 +328,33 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
|
||||
throw new Error(`Invalid journal slug: "${slug}"`);
|
||||
}
|
||||
|
||||
const filePath = resolveJournalFilePath(slug);
|
||||
if (!filePath) return false;
|
||||
const filename = `${slug}.md`;
|
||||
const primaryPath = containedPath(JOURNAL_DIR, filename);
|
||||
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
|
||||
if (!primaryPath || !legacyPath) return false;
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
// Check both directories, not just whichever resolveJournalFilePath would
|
||||
// pick: an entry that was saved once while still in content/essays/ used to
|
||||
// leave a copy there, which listJournalEntries would bring back as soon as
|
||||
// the content/journal/ copy was deleted (#631). writeJournalEntry now
|
||||
// retires the legacy copy on every save, but a legacy entry that was never
|
||||
// edited still lives there alone, so both paths must be checked here too.
|
||||
let deletedAny = false;
|
||||
for (const filePath of [primaryPath, legacyPath]) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
await snapshotEntry(filePath, filename, 'deleted');
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
deletedAny = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
await snapshotEntry(filePath, `${slug}.md`, 'deleted');
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
console.log(`[Journal] 🗑️ Deleted ${slug}.md`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`);
|
||||
return deletedAny;
|
||||
}
|
||||
|
||||
32
lib/admin/withAdmin.ts
Normal file
32
lib/admin/withAdmin.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from './auth';
|
||||
|
||||
/**
|
||||
* Wraps an admin route handler in the enabled + authenticated check.
|
||||
*
|
||||
* Every handler under /api/admin is defined inside this wrapper, so a route
|
||||
* that forgets its guard is not a diff that merely looks fine: there is no
|
||||
* plain `export async function GET` left to copy from. The one exception is
|
||||
* /api/admin/auth, the login endpoint, which must stay reachable without a
|
||||
* session.
|
||||
*
|
||||
* The two refusals are kept distinct, as the hand-written copies had them:
|
||||
* 403 means the admin panel is switched off for this deployment, 401 means it
|
||||
* is on and this caller is not signed in.
|
||||
*
|
||||
* The handler's own parameters are passed through untouched, so routes taking
|
||||
* `(request)`, `(request, context)` or nothing at all all wrap the same way.
|
||||
*/
|
||||
export function withAdmin<Args extends unknown[]>(
|
||||
handler: (...args: Args) => Response | Promise<Response>,
|
||||
): (...args: Args) => Promise<Response> {
|
||||
return async (...args: Args): Promise<Response> => {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return handler(...args);
|
||||
};
|
||||
}
|
||||
@@ -6,14 +6,12 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import type { GalleryYaml, SettingsYaml } from '../config/schema';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const MAX_BACKUPS = 10; // Keep last 10 backups per file
|
||||
|
||||
/** Disambiguates temp files within one process; the pid covers across them. */
|
||||
let tmpCounter = 0;
|
||||
|
||||
/** Read gallery.yaml and return parsed content. */
|
||||
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
|
||||
try {
|
||||
@@ -43,9 +41,21 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
// Ensure content directory exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
|
||||
// Create backup if file exists
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to (owned by root after a
|
||||
// first start as root, say) looked exactly like a brand-new file: the save
|
||||
// went ahead with no snapshot taken (#630). Only ENOENT means there is
|
||||
// nothing to back up; anything else aborts the save before it overwrites
|
||||
// the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
@@ -55,8 +65,6 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
|
||||
// Prune old backups
|
||||
await pruneBackups(backupDir, filename);
|
||||
} catch {
|
||||
// File doesn't exist yet, no backup needed
|
||||
}
|
||||
|
||||
// Generate YAML content with header comment
|
||||
@@ -74,23 +82,7 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
// Atomic write: write to a *unique* temp file, then rename.
|
||||
//
|
||||
// The rename is what makes this atomic; the temp filename is what did not.
|
||||
// With a constant `${filePath}.tmp`, two saves of the same file in flight at
|
||||
// once shared it — the second writeFile could interleave with the first's
|
||||
// rename, leaving one save's bytes published under the other's, or a
|
||||
// truncated mix. A double-clicked Save in the page builder is enough.
|
||||
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
// Do not leave litter in content/ behind a failed save. Cleanup failure is
|
||||
// not worth masking the real error with.
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(filePath, content);
|
||||
|
||||
console.log(`[Admin] ✅ Saved ${filename}`);
|
||||
}
|
||||
@@ -148,31 +140,63 @@ export async function restoreBackup(backupFilename: string): Promise<void> {
|
||||
const originalFilename = match[1];
|
||||
const targetPath = path.join(CONTENT_DIR, originalFilename);
|
||||
|
||||
// Create a backup of current state first
|
||||
// Read before touching anything, so a missing or unreadable backup changes
|
||||
// nothing on disk.
|
||||
const content = await fs.readFile(backupPath);
|
||||
|
||||
// Create a backup of current state first. A failed safety copy aborts the
|
||||
// restore rather than being swallowed: `fs.copyFile` below would otherwise
|
||||
// truncate `targetPath` before writing it, so on a full disk or a container
|
||||
// killed mid-copy the live file could be lost with no snapshot to fall back
|
||||
// on (#630). Only ENOENT — there is no current file to snapshot, which is
|
||||
// the normal case for a deleted entry — is not an abort.
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const preRestoreBackup = `${originalFilename}.${timestamp}.pre-restore.bak`;
|
||||
try {
|
||||
await fs.copyFile(targetPath, path.join(backupDir, preRestoreBackup));
|
||||
} catch {
|
||||
// Original might not exist
|
||||
// Bound them here rather than waiting for the next save: restoring twice in
|
||||
// a row is exactly when these pile up, and a save may be a long way off.
|
||||
await pruneBackups(backupDir, originalFilename);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
await fs.copyFile(backupPath, targetPath);
|
||||
// atomicWrite rather than fs.copyFile: a copy truncates the destination
|
||||
// before writing, so a failure partway through (full disk, killed
|
||||
// container) leaves targetPath empty. Temp-file-then-rename means the old
|
||||
// content stays in place until the new content is fully on disk.
|
||||
await atomicWrite(targetPath, content);
|
||||
console.log(`[Admin] 🔄 Restored ${originalFilename} from ${backupFilename}`);
|
||||
}
|
||||
|
||||
/** Remove old backups, keeping only MAX_BACKUPS most recent. */
|
||||
/**
|
||||
* Remove old backups, keeping the newest MAX_BACKUPS of each kind.
|
||||
*
|
||||
* The two kinds are counted separately on purpose: ten ordinary saves must not
|
||||
* push out the snapshot taken just before a restore, which is the only way back
|
||||
* from one. That is why pre-restore files were exempt from pruning.
|
||||
*
|
||||
* Exempting them entirely was the bug, though. listBackups() returns them and
|
||||
* the modal lists them, so every restore added a row that was never removed
|
||||
* until the useful backups were off the end of the list. A cap of their own
|
||||
* keeps the undo without letting it crowd out everything else.
|
||||
*/
|
||||
async function pruneBackups(backupDir: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.readdir(backupDir);
|
||||
const relevant = files
|
||||
.filter((f) => f.startsWith(filename) && !f.includes('pre-restore'))
|
||||
.sort();
|
||||
|
||||
if (relevant.length > MAX_BACKUPS) {
|
||||
const toDelete = relevant.slice(0, relevant.length - MAX_BACKUPS);
|
||||
for (const f of toDelete) {
|
||||
await fs.unlink(path.join(backupDir, f));
|
||||
const saves: string[] = [];
|
||||
const preRestores: string[] = [];
|
||||
for (const f of files.filter((f) => f.startsWith(filename))) {
|
||||
(f.includes('pre-restore') ? preRestores : saves).push(f);
|
||||
}
|
||||
|
||||
// The timestamp is fixed-width and lexically ordered, so sorting by name
|
||||
// sorts by age.
|
||||
for (const group of [saves, preRestores]) {
|
||||
group.sort();
|
||||
for (const old of group.slice(0, Math.max(0, group.length - MAX_BACKUPS))) {
|
||||
await fs.unlink(path.join(backupDir, old));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user