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>
128 lines
5.0 KiB
TypeScript
128 lines
5.0 KiB
TypeScript
/**
|
|
* Concurrent-edit detection for the admin's content files (#601).
|
|
*
|
|
* Every editor holds a full snapshot of the file it edits and PUTs all of it,
|
|
* so without a check the tab that saves second silently reverts whatever the
|
|
* first one changed — as does a panel left open across a hand edit or a backup
|
|
* restore. Each GET therefore hands out a version of the file it read, the PUT
|
|
* sends it back in `If-Match`, and the write is refused with 409 when the file
|
|
* on disk is no longer that version.
|
|
*
|
|
* The version is a hash of the bytes, not the mtime: the mtime has a
|
|
* resolution the filesystem chooses (a second on some), survives a copy that
|
|
* changes the content (`cp -p`, a restore from a backup of equal age) and
|
|
* changes on a `touch` that does not. The files are small, so hashing one per
|
|
* save costs nothing that matters.
|
|
*
|
|
* A PUT without `If-Match` is written as before. That keeps an older admin tab
|
|
* (loaded before the upgrade) and scripts working, and the check is a guard
|
|
* against accidents, not an access control: an operator who wants to overwrite
|
|
* can always reload and save. `If-Match: *` means the same — write regardless.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import fs from 'fs/promises';
|
|
import { NextResponse } from 'next/server';
|
|
|
|
/** The version of a file that does not exist. A save expecting it creates the file. */
|
|
export const ABSENT_VERSION = 'absent';
|
|
|
|
/**
|
|
* Version of some content; null content means "no file". A string is hashed
|
|
* as its UTF-8 bytes, which is what a save writes — so it fits content about
|
|
* to be written, not text decoded from a file (see readVersioned).
|
|
*/
|
|
export function versionOf(content: string | Buffer | null): string {
|
|
if (content === null) return ABSENT_VERSION;
|
|
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 32);
|
|
}
|
|
|
|
/**
|
|
* Read a text file together with the version of its bytes, for an editor's
|
|
* GET. The version has to come from the bytes, never from the decoded text:
|
|
* a file that is not valid UTF-8 (a Latin-1 "Über" from a hand edit) decodes
|
|
* lossily, the text re-encodes to other bytes, and a version taken from it
|
|
* would never equal what `fileVersion()` computes at save time — every save
|
|
* of that file would be refused as a conflict. Throws ENOENT like readFile.
|
|
*/
|
|
export async function readVersioned(filePath: string): Promise<{ text: string; version: string }> {
|
|
const bytes = await fs.readFile(filePath);
|
|
return { text: bytes.toString('utf8'), version: versionOf(bytes) };
|
|
}
|
|
|
|
/** Version of the file at `filePath` as it is on disk now. */
|
|
export async function fileVersion(filePath: string): Promise<string> {
|
|
try {
|
|
return versionOf(await fs.readFile(filePath));
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ABSENT_VERSION;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** The file changed on disk since the editor loaded it. */
|
|
export class VersionConflictError extends Error {
|
|
constructor(readonly currentVersion: string) {
|
|
super('The file was changed elsewhere since it was loaded.');
|
|
this.name = 'VersionConflictError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throw a VersionConflictError when `filePath` is not at `baseVersion`.
|
|
* An undefined base version (no `If-Match`) skips the check.
|
|
*/
|
|
export async function assertVersion(
|
|
filePath: string,
|
|
baseVersion: string | undefined,
|
|
): Promise<void> {
|
|
if (baseVersion === undefined) return;
|
|
const current = await fileVersion(filePath);
|
|
if (current !== baseVersion) throw new VersionConflictError(current);
|
|
}
|
|
|
|
/**
|
|
* The base version a PUT sent, or undefined for "no check". Accepts the
|
|
* quoted ETag form (`"abc"`, `W/"abc"`) as well as a bare token; `*` and an
|
|
* empty header mean no check.
|
|
*/
|
|
export function baseVersionFrom(request: Request): string | undefined {
|
|
const raw = request.headers.get('if-match')?.trim();
|
|
if (!raw || raw === '*') return undefined;
|
|
return raw.replace(/^W\//, '').replace(/^"(.*)"$/, '$1');
|
|
}
|
|
|
|
/** The header form of a version. */
|
|
export function etag(version: string): string {
|
|
return `"${version}"`;
|
|
}
|
|
|
|
/** The 409 every content route answers a stale save with. */
|
|
export function conflictResponse(currentVersion: string): NextResponse {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'This was changed elsewhere since you opened it.',
|
|
conflict: true,
|
|
version: currentVersion,
|
|
},
|
|
{ status: 409, headers: { ETag: etag(currentVersion) } },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run content writes one at a time within this process.
|
|
*
|
|
* The version check and the write it guards must not interleave with another
|
|
* save, or two tabs saving in the same instant would both pass the check. A
|
|
* single queue for all content files is enough — admin saves are rare and
|
|
* short — and it also serialises a rename's write-then-delete. Like the rate
|
|
* limiter it is per process; Folio runs as one.
|
|
*/
|
|
let queue: Promise<unknown> = Promise.resolve();
|
|
|
|
export function serializeContentWrite<T>(fn: () => Promise<T>): Promise<T> {
|
|
const run = queue.then(fn, fn);
|
|
queue = run.catch(() => {});
|
|
return run;
|
|
}
|