release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- 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> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
165
vitest.content-guard.ts
Normal file
165
vitest.content-guard.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Refuses every filesystem write into the checkout's own content/ directory
|
||||
* while the unit suite runs.
|
||||
*
|
||||
* In a self-hosted deployment that directory is the live content — the
|
||||
* container mounts it — so a test that writes a journal entry there publishes
|
||||
* it for as long as the test runs and leaves a `.deleted.bak` behind in
|
||||
* content/journal/.backups. Tests point `process.cwd()` at a temp directory
|
||||
* instead (see journal-backup.test.ts); this guard makes forgetting that fail
|
||||
* loudly rather than quietly touching the site.
|
||||
*
|
||||
* Why patch fs rather than snapshot the directory before and after: the live
|
||||
* site writes analytics.json and contact messages into the same directory at
|
||||
* any moment, which a snapshot cannot tell apart from a test. Patching sees
|
||||
* only this process's writes, blocks them before they happen, and catches a
|
||||
* file that is written and deleted again within one test.
|
||||
*
|
||||
* A blocked write throws, and is also recorded, because service code often
|
||||
* swallows errors (`catch {}` in cleanup hooks, `.catch(() => {})` after a
|
||||
* failed rename); the afterEach below turns any record into a test failure.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, afterEach } from 'vitest';
|
||||
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
interface GuardState {
|
||||
contentDir: string;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
const STATE_KEY = Symbol.for('folio.contentWriteGuard');
|
||||
|
||||
// Tests spy on process.cwd(); the bound original keeps resolving relative
|
||||
// paths the way the OS does, against the real working directory.
|
||||
const realCwd = process.cwd.bind(process);
|
||||
|
||||
function toPath(p: unknown): string | null {
|
||||
if (typeof p === 'string') return p;
|
||||
if (Buffer.isBuffer(p)) return p.toString();
|
||||
if (p instanceof URL) return p.protocol === 'file:' ? fileURLToPath(p) : null;
|
||||
return null; // a file descriptor or FileHandle: nothing to resolve
|
||||
}
|
||||
|
||||
/** Flags that can create, truncate or change a file. */
|
||||
function isWriteFlag(flags: unknown): boolean {
|
||||
if (flags === undefined || flags === null) return false;
|
||||
if (typeof flags === 'number') {
|
||||
const { O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND } = fs.constants;
|
||||
return (flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) !== 0;
|
||||
}
|
||||
return typeof flags === 'string' && /[wa+]/.test(flags);
|
||||
}
|
||||
|
||||
function install(): GuardState {
|
||||
const existing = (fs as unknown as Record<symbol, GuardState | undefined>)[STATE_KEY];
|
||||
if (existing) return existing;
|
||||
|
||||
const state: GuardState = {
|
||||
contentDir: path.join(path.dirname(fileURLToPath(import.meta.url)), 'content'),
|
||||
violations: [],
|
||||
};
|
||||
|
||||
const check = (op: string, p: unknown) => {
|
||||
const raw = toPath(p);
|
||||
if (raw === null) return;
|
||||
const resolved = path.resolve(realCwd(), raw);
|
||||
if (resolved !== state.contentDir && !resolved.startsWith(state.contentDir + path.sep)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
`Test tried to ${op} ${resolved} — the real content/ directory. ` +
|
||||
`Point process.cwd() at a temp directory first (see journal-backup.test.ts).`;
|
||||
state.violations.push(message);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
// Which arguments are destination paths. rename moves the source too, so
|
||||
// both of its paths count; copies and links only create their second.
|
||||
const targets: Record<string, number[]> = {
|
||||
writeFile: [0],
|
||||
appendFile: [0],
|
||||
mkdir: [0],
|
||||
mkdtemp: [0],
|
||||
rm: [0],
|
||||
rmdir: [0],
|
||||
unlink: [0],
|
||||
truncate: [0],
|
||||
chmod: [0],
|
||||
chown: [0],
|
||||
lchown: [0],
|
||||
utimes: [0],
|
||||
lutimes: [0],
|
||||
rename: [0, 1],
|
||||
copyFile: [1],
|
||||
cp: [1],
|
||||
link: [1],
|
||||
symlink: [1],
|
||||
};
|
||||
|
||||
// A promise-returning fs function never throws synchronously, so the
|
||||
// fs.promises wrappers reject instead — a `.catch()` on the call must see it.
|
||||
const wrap = (
|
||||
owner: Record<string, unknown>,
|
||||
name: string,
|
||||
guard: (args: unknown[]) => void,
|
||||
rejects = false,
|
||||
) => {
|
||||
const original = owner[name];
|
||||
if (typeof original !== 'function') return;
|
||||
owner[name] = function guarded(this: unknown, ...args: unknown[]) {
|
||||
try {
|
||||
guard(args);
|
||||
} catch (err) {
|
||||
if (rejects) return Promise.reject(err);
|
||||
throw err;
|
||||
}
|
||||
return (original as AnyFn).apply(this, args);
|
||||
};
|
||||
};
|
||||
|
||||
const fsObj = fs as unknown as Record<string, unknown>;
|
||||
const promises = fs.promises as unknown as Record<string, unknown>;
|
||||
for (const [name, indices] of Object.entries(targets)) {
|
||||
const guard = (args: unknown[]) => indices.forEach((i) => check(name, args[i]));
|
||||
wrap(fsObj, name, guard);
|
||||
wrap(fsObj, `${name}Sync`, guard);
|
||||
wrap(promises, name, guard, true);
|
||||
}
|
||||
const openGuard = (args: unknown[]) => {
|
||||
if (isWriteFlag(args[1])) check('open for writing', args[0]);
|
||||
};
|
||||
wrap(fsObj, 'open', openGuard);
|
||||
wrap(fsObj, 'openSync', openGuard);
|
||||
wrap(promises, 'open', openGuard, true);
|
||||
wrap(fsObj, 'createWriteStream', (args) => check('stream into', args[0]));
|
||||
|
||||
// `import { writeFile } from 'fs/promises'` binds the export once; this
|
||||
// pushes the wrapped functions into those bindings too.
|
||||
syncBuiltinESMExports();
|
||||
|
||||
Object.defineProperty(fs, STATE_KEY, { value: state });
|
||||
return state;
|
||||
}
|
||||
|
||||
const state = install();
|
||||
|
||||
/** Returns and clears the recorded violations. For the guard's own test. */
|
||||
export function takeContentWriteViolations(): string[] {
|
||||
return state.violations.splice(0);
|
||||
}
|
||||
|
||||
/** The directory the guard protects. For the guard's own test. */
|
||||
export const guardedContentDir = state.contentDir;
|
||||
|
||||
function failOnViolations() {
|
||||
const found = takeContentWriteViolations();
|
||||
if (found.length > 0) throw new Error(found.join('\n'));
|
||||
}
|
||||
|
||||
afterEach(failOnViolations);
|
||||
afterAll(failOnViolations);
|
||||
Reference in New Issue
Block a user