@glw907/cairn-cms 0.57.1 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/components/CairnMediaLibrary.svelte +2070 -26
  3. package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
  4. package/dist/components/admin-icons.d.ts +5 -0
  5. package/dist/components/admin-icons.js +5 -0
  6. package/dist/components/cairn-admin.css +402 -3
  7. package/dist/content/media-rewrite.d.ts +65 -0
  8. package/dist/content/media-rewrite.js +442 -0
  9. package/dist/log/events.d.ts +1 -1
  10. package/dist/media/bulk-delete-plan.d.ts +24 -0
  11. package/dist/media/bulk-delete-plan.js +25 -0
  12. package/dist/media/orphan-scan.d.ts +37 -0
  13. package/dist/media/orphan-scan.js +42 -0
  14. package/dist/media/reconcile.d.ts +3 -0
  15. package/dist/media/reconcile.js +3 -2
  16. package/dist/media/rewrite-plan.d.ts +65 -0
  17. package/dist/media/rewrite-plan.js +61 -0
  18. package/dist/sveltekit/cairn-admin.d.ts +8 -0
  19. package/dist/sveltekit/cairn-admin.js +15 -0
  20. package/dist/sveltekit/content-routes.d.ts +118 -4
  21. package/dist/sveltekit/content-routes.js +572 -1
  22. package/dist/sveltekit/index.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/lib/components/CairnMediaLibrary.svelte +2070 -26
  25. package/src/lib/components/admin-icons.ts +5 -0
  26. package/src/lib/content/media-rewrite.ts +555 -0
  27. package/src/lib/log/events.ts +6 -1
  28. package/src/lib/media/bulk-delete-plan.ts +54 -0
  29. package/src/lib/media/orphan-scan.ts +74 -0
  30. package/src/lib/media/reconcile.ts +3 -2
  31. package/src/lib/media/rewrite-plan.ts +122 -0
  32. package/src/lib/sveltekit/cairn-admin.ts +15 -0
  33. package/src/lib/sveltekit/content-routes.ts +722 -5
  34. package/src/lib/sveltekit/index.ts +3 -0
@@ -26,6 +26,11 @@ import { r2Store } from '../media/store.js';
26
26
  import { parseMediaEntries, parseMediaManifest, upsertMediaEntry, removeMediaEntry, serializeMediaManifest } from '../media/manifest.js';
27
27
  import { mediaLibraryEntry } from '../media/library-entry.js';
28
28
  import { buildUsageIndex } from '../media/usage.js';
29
+ import { runReconcile, MEDIA_KEY_RE } from '../media/reconcile.js';
30
+ import { buildOrphanScan } from '../media/orphan-scan.js';
31
+ import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
32
+ import { planMediaRewrite } from '../media/rewrite-plan.js';
33
+ import { planBulkDelete } from '../media/bulk-delete-plan.js';
29
34
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
30
35
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
31
36
  * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
@@ -227,6 +232,14 @@ export function createContentRoutes(runtime, deps = {}) {
227
232
  flash = 'deleted';
228
233
  else if (event.url.searchParams.get('updated') === '1')
229
234
  flash = 'updated';
235
+ else if (event.url.searchParams.get('replaced') === '1')
236
+ flash = 'replaced';
237
+ else if (event.url.searchParams.get('altPropagated') === '1')
238
+ flash = 'altPropagated';
239
+ else if (event.url.searchParams.get('bulkDeleted') === '1')
240
+ flash = 'bulkDeleted';
241
+ else if (event.url.searchParams.get('orphansPurged') === '1')
242
+ flash = 'orphansPurged';
230
243
  const flashError = event.url.searchParams.get('error');
231
244
  let token;
232
245
  try {
@@ -1067,6 +1080,245 @@ export function createContentRoutes(runtime, deps = {}) {
1067
1080
  log.info('media.deleted', { editor: editor.email, hash });
1068
1081
  throw redirect(303, '/admin/media?deleted=1');
1069
1082
  }
1083
+ /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1084
+ * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1085
+ * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1086
+ * would blow the workerd connection budget at many open branches). The fail-closed posture is for
1087
+ * the WHOLE batch: if that single strict index cannot complete, the action refuses everything and
1088
+ * commits nothing, rather than risk deleting bytes a branch still references.
1089
+ *
1090
+ * Skip-and-report, never force: the pure planBulkDelete partitions the selection against the strict
1091
+ * index into deletable (no usage row, a committed manifest row exists), skipped-still-referenced (a
1092
+ * usage row, carried for the where-used), and skipped-uncommitted (no manifest row). An in-use item
1093
+ * is skipped and reported, never bulk-force-deleted; forced in-use deletion stays the single-item
1094
+ * typed-slug path.
1095
+ *
1096
+ * The order is load-bearing, mirroring single delete: ONE atomic commit removes every deletable row
1097
+ * FIRST, then the R2 objects are deleted (commit-row-then-delete-R2). A failure after the commit
1098
+ * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1099
+ * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1100
+ * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1101
+ * (deleted / skipped with reasons / failed); there is no success redirect. */
1102
+ async function mediaBulkDelete(event) {
1103
+ const editor = requireSession(event);
1104
+ const token = await mintToken(event.platform?.env ?? {});
1105
+ // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1106
+ // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
1107
+ // dropped silently rather than surfaced as a skip (it was never a real selection).
1108
+ const form = await event.request.formData();
1109
+ let raw = form.getAll('hash').map(String);
1110
+ if (raw.length === 0) {
1111
+ const json = form.get('hashes');
1112
+ if (typeof json === 'string') {
1113
+ try {
1114
+ const parsed = JSON.parse(json);
1115
+ if (Array.isArray(parsed))
1116
+ raw = parsed.map(String);
1117
+ }
1118
+ catch {
1119
+ raw = [];
1120
+ }
1121
+ }
1122
+ }
1123
+ const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
1124
+ // Read the fresh media manifest (the deletable rows come from here, by hash).
1125
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1126
+ // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
1127
+ // the commit, exactly like single delete.
1128
+ const resolved = runtime.resolvedAssets;
1129
+ if (!resolved.enabled) {
1130
+ return fail(503, { error: 'Media is not enabled for this site.' });
1131
+ }
1132
+ const platformEnv = event.platform?.env ?? {};
1133
+ const rawBucket = platformEnv[resolved.bucketBinding];
1134
+ if (!rawBucket) {
1135
+ return fail(503, { error: 'The media bucket is not bound.' });
1136
+ }
1137
+ const store = r2Store(rawBucket);
1138
+ // THE fail-closed gate for the whole batch: one shared strict usage index. STRICT mode rethrows a
1139
+ // branch-read failure, so a transient branch read failing refuses the whole batch rather than
1140
+ // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
1141
+ let index;
1142
+ try {
1143
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1144
+ }
1145
+ catch {
1146
+ return fail(503, { error: 'Could not verify where these assets are used. Try again.' });
1147
+ }
1148
+ // The pure partition: membership in the fresh strict index is the gate, never the display count.
1149
+ const plan = planBulkDelete(selected, index, manifest);
1150
+ // An all-skipped or empty batch is a no-op success: nothing committed, nothing deleted.
1151
+ if (plan.deletable.length === 0) {
1152
+ return { deleted: [], skipped: plan.skipped, failed: [] };
1153
+ }
1154
+ // ONE atomic commit removing EVERY deletable row, folded over removeMediaEntry.
1155
+ let next = manifest;
1156
+ for (const hash of plan.deletable)
1157
+ next = removeMediaEntry(next, hash);
1158
+ const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
1159
+ try {
1160
+ await commitFiles(runtime.backend, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }], { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } }, token);
1161
+ log.info('commit.succeeded', commitFields);
1162
+ }
1163
+ catch (err) {
1164
+ commitFailure(commitFields, err, '/admin/media', 'The media manifest changed since you opened it. Reload and try again.');
1165
+ }
1166
+ // THEN delete each deletable hash's R2 object (the load-bearing order, see the docstring). Best
1167
+ // effort and batch-resilient: a thrown key derivation or a delete error is reported in `failed`
1168
+ // and the loop continues. An absent object is a no-op (the R2 contract).
1169
+ const deleted = [];
1170
+ const failed = [];
1171
+ for (const hash of plan.deletable) {
1172
+ try {
1173
+ const row = manifest[hash];
1174
+ await store.delete(r2Key(row.hash, row.ext));
1175
+ deleted.push(hash);
1176
+ }
1177
+ catch (err) {
1178
+ failed.push({ hash, error: err instanceof Error ? err.message : String(err) });
1179
+ }
1180
+ }
1181
+ log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
1182
+ return { deleted, skipped: plan.skipped, failed };
1183
+ }
1184
+ /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1185
+ * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1186
+ * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1187
+ * plus a reconcile pass on top of the strict usage build.
1188
+ *
1189
+ * Detection-time fail-closed: BOTH the reconcile and the strict usage build run inside one
1190
+ * try/catch, and any throw refuses the whole scan with fail(503) rather than returning a partial
1191
+ * result. The reconcile must not run on a half-listed bucket: a truncated R2 list would call
1192
+ * still-stored bytes orphaned. The strict usage build must not run on a half-read branch set: an
1193
+ * unread branch would make a branch-referenced asset look orphaned. A wrong orphan verdict here
1194
+ * feeds the irreversible purge, so the scan refuses rather than risk it.
1195
+ *
1196
+ * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1197
+ * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1198
+ * where-used so an operator can re-ingest rather than purge a still-referenced record). */
1199
+ async function mediaOrphanScan(event) {
1200
+ requireSession(event);
1201
+ const token = await mintToken(event.platform?.env ?? {});
1202
+ // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
1203
+ // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
1204
+ const resolved = runtime.resolvedAssets;
1205
+ if (!resolved.enabled) {
1206
+ return fail(503, { error: 'Media is not enabled for this site.' });
1207
+ }
1208
+ const platformEnv = event.platform?.env ?? {};
1209
+ const rawBucket = platformEnv[resolved.bucketBinding];
1210
+ if (!rawBucket) {
1211
+ return fail(503, { error: 'The media bucket is not bound.' });
1212
+ }
1213
+ // Read the fresh media manifest for the reconcile's manifest side.
1214
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1215
+ // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
1216
+ // and the strict usage build (a branch read that must complete in full) are both unsafe to use
1217
+ // partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
1218
+ // would feed the irreversible purge.
1219
+ let reconcile;
1220
+ let index;
1221
+ try {
1222
+ reconcile = await runReconcile(rawBucket, manifest);
1223
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1224
+ }
1225
+ catch {
1226
+ return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' });
1227
+ }
1228
+ return buildOrphanScan(reconcile, manifest, index);
1229
+ }
1230
+ /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1231
+ * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1232
+ * history. The whole action is built around that fact.
1233
+ *
1234
+ * The typed-count confirm is the never-bypassable gate, the analogue of single delete's typed-slug
1235
+ * check. The form's `confirm` must equal the count of selected keys (the approved rev.2 mockup's
1236
+ * "Type N to purge these files for good"); an empty selection or a mismatched count deletes nothing.
1237
+ *
1238
+ * Re-derive fresh is the safety crux. The selection came from an earlier scan, so the action does
1239
+ * NOT trust it: the purge keys are client-posted, so the server cannot assume they came from a fresh
1240
+ * scan. It reads the current media manifest AND rebuilds ONE strict cross-branch usage index, then
1241
+ * for each selected key parses the hash from the key grammar. A key that does not match the grammar
1242
+ * was never a real orphan key and is dropped silently. A key whose hash now has a manifest row OR is
1243
+ * referenced on any open cairn/* branch survived the scan window (it was claimed by a row, or a
1244
+ * draft started referencing those bytes), so it is skipped into skippedClaimed and its bytes survive.
1245
+ * Only a key whose hash is STILL absent from both is purged. This closes the TOCTOU between scan and
1246
+ * purge that could otherwise irreversibly delete a live draft's bytes.
1247
+ *
1248
+ * Like the scan and the bulk delete, the strict index build is the fail-closed gate: a branch read
1249
+ * that throws refuses the whole batch with fail(503) rather than mistaking an unverifiable reference
1250
+ * for an absent one. The index is built exactly once for the batch, never once per key.
1251
+ *
1252
+ * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1253
+ * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1254
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
1255
+ async function mediaPurgeOrphans(event) {
1256
+ const editor = requireSession(event);
1257
+ const token = await mintToken(event.platform?.env ?? {});
1258
+ // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
1259
+ // deletes through the MediaStore seam, so wrap the raw binding.
1260
+ const resolved = runtime.resolvedAssets;
1261
+ if (!resolved.enabled) {
1262
+ return fail(503, { error: 'Media is not enabled for this site.' });
1263
+ }
1264
+ const platformEnv = event.platform?.env ?? {};
1265
+ const rawBucket = platformEnv[resolved.bucketBinding];
1266
+ if (!rawBucket) {
1267
+ return fail(503, { error: 'The media bucket is not bound.' });
1268
+ }
1269
+ const store = r2Store(rawBucket);
1270
+ // Read the selected R2 keys and the typed confirm.
1271
+ const form = await event.request.formData();
1272
+ const keys = form.getAll('key').map(String);
1273
+ const confirm = String(form.get('confirm') ?? '');
1274
+ // The irreversible gate: the confirm must equal the selected count, and the set must be non-empty.
1275
+ // A mismatch or an empty set refuses and deletes NOTHING.
1276
+ if (keys.length === 0 || confirm !== String(keys.length)) {
1277
+ return fail(400, { error: 'Type the number of files to confirm the purge.' });
1278
+ }
1279
+ // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
1280
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1281
+ // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
1282
+ // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
1283
+ // branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
1284
+ // treated as a true orphan. Build exactly one index, never one per key.
1285
+ let index;
1286
+ try {
1287
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1288
+ }
1289
+ catch {
1290
+ return fail(503, { error: 'Could not verify where these files are used. Try again.' });
1291
+ }
1292
+ const purged = [];
1293
+ const skippedClaimed = [];
1294
+ const failed = [];
1295
+ for (const key of keys) {
1296
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
1297
+ // A key that does not match the grammar was never a real orphan key: drop it silently.
1298
+ if (hash === undefined)
1299
+ continue;
1300
+ // A hash that now has a manifest row was claimed since the scan: its bytes are a live asset now.
1301
+ if (manifest[hash]) {
1302
+ skippedClaimed.push(key);
1303
+ continue;
1304
+ }
1305
+ // A hash referenced on any open cairn/* branch backs an in-progress draft: skip it claimed too.
1306
+ if (index.has(hash)) {
1307
+ skippedClaimed.push(key);
1308
+ continue;
1309
+ }
1310
+ // Still orphaned: delete the object directly. No commit, there is no manifest row.
1311
+ try {
1312
+ await store.delete(key);
1313
+ purged.push(key);
1314
+ }
1315
+ catch (err) {
1316
+ failed.push({ key, error: err instanceof Error ? err.message : String(err) });
1317
+ }
1318
+ }
1319
+ log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
1320
+ return { purged, skippedClaimed, failed };
1321
+ }
1070
1322
  /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1071
1323
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1072
1324
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
@@ -1100,7 +1352,326 @@ export function createContentRoutes(runtime, deps = {}) {
1100
1352
  }
1101
1353
  throw redirect(303, '/admin/media?updated=1');
1102
1354
  }
1103
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1355
+ /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
1356
+ * an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
1357
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
1358
+ function replacementToken(slug, hash) {
1359
+ return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
1360
+ }
1361
+ /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
1362
+ * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
1363
+ * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
1364
+ * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
1365
+ * rather than a partial plan, so the confirm dialog never shows a count it cannot stand behind.
1366
+ *
1367
+ * Wire contract: a fetch POST with the JSON body `{ oldHash, newHash, slug }`, the CSRF token in
1368
+ * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
1369
+ * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
1370
+ * with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
1371
+ * `type`/`status` from the body, never the HTTP status. */
1372
+ async function mediaReplacePreview(event) {
1373
+ // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
1374
+ // upload action. A failed check refuses before the session read or any GitHub call.
1375
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1376
+ return fail(403, { error: 'csrf', hash: '', usage: [], foundIn: 0 });
1377
+ }
1378
+ requireSession(event);
1379
+ // Parse the JSON body. A malformed body or a hash that fails the 16-hex grammar refuses with a 400
1380
+ // before any GitHub read. The slug is the OLD asset's: a replace keeps the name and changes only the
1381
+ // content hash, so the repointed token carries the existing slug (an invalid slug falls back to a
1382
+ // bare-hash token below). It is cosmetic for the preview display; the apply re-derives it server-side.
1383
+ let payload;
1384
+ try {
1385
+ payload = JSON.parse(await event.request.text());
1386
+ }
1387
+ catch {
1388
+ return fail(400, { error: 'Could not read the replace request.', hash: '', usage: [], foundIn: 0 });
1389
+ }
1390
+ const oldHash = String(payload.oldHash ?? '');
1391
+ const newHash = String(payload.newHash ?? '');
1392
+ const slug = String(payload.slug ?? '');
1393
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
1394
+ return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 });
1395
+ }
1396
+ const token = await mintToken(event.platform?.env ?? {});
1397
+ const contentManifest = await readManifest(token);
1398
+ const newToken = replacementToken(slug, newHash);
1399
+ // Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
1400
+ // throws out of here rather than degrading to an absent reference; catch it and fail closed, the
1401
+ // same posture the delete gate takes.
1402
+ let plan;
1403
+ try {
1404
+ plan = await planMediaRewrite({
1405
+ backend: runtime.backend,
1406
+ token,
1407
+ concepts: runtime.concepts,
1408
+ contentManifest,
1409
+ hash: oldHash,
1410
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1411
+ });
1412
+ }
1413
+ catch {
1414
+ return fail(503, {
1415
+ error: 'Could not verify where this asset is used. Try again.',
1416
+ hash: oldHash,
1417
+ usage: [],
1418
+ foundIn: 0,
1419
+ });
1420
+ }
1421
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
1422
+ // carries neither). A planned entry always has a manifest row (the usage index is built from the
1423
+ // manifest), so the lookup hits; an id-only fallback keeps the type total if a row is ever absent.
1424
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
1425
+ const entries = plan.entries.map((e) => {
1426
+ const row = byKey.get(`${e.concept}/${e.id}`);
1427
+ return {
1428
+ concept: e.concept,
1429
+ id: e.id,
1430
+ title: row?.title ?? e.id,
1431
+ permalink: row?.permalink,
1432
+ placements: e.placements,
1433
+ };
1434
+ });
1435
+ return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
1436
+ }
1437
+ /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
1438
+ * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
1439
+ * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
1440
+ * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
1441
+ * (unlike delete, which only gates an in-use asset): a replace silently repoints published content,
1442
+ * so it always demands the type-to-confirm. An empty stored slug is never satisfiable, exactly like
1443
+ * delete. The plan runs strict, so an unverifiable usage read fails the replace closed (commits
1444
+ * nothing) rather than rewriting some references and leaving others.
1445
+ *
1446
+ * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
1447
+ * bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
1448
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
1449
+ async function mediaReplaceApply(event) {
1450
+ const editor = requireSession(event);
1451
+ const token = await mintToken(event.platform?.env ?? {});
1452
+ const form = await event.request.formData();
1453
+ const oldHash = String(form.get('oldHash') ?? '');
1454
+ const newHash = String(form.get('newHash') ?? '');
1455
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash))
1456
+ throw error(400, 'Invalid media hash');
1457
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1458
+ // The new asset's optimistic record rides the post (the same untrusted-record contract as save).
1459
+ // Find the row for newHash; its absence is a malformed or missing replacement, a 400.
1460
+ const record = parseMediaEntries(form.get('media')).find((r) => r.hash === newHash);
1461
+ if (!record) {
1462
+ return fail(400, {
1463
+ error: 'The replacement upload is missing or invalid.',
1464
+ hash: oldHash,
1465
+ usage: [],
1466
+ foundIn: 0,
1467
+ });
1468
+ }
1469
+ // The old asset must be committed on main to be replaceable here. A branch-only upload has no main
1470
+ // row; it is replaced by editing its draft, not here.
1471
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1472
+ const row = manifest[oldHash];
1473
+ if (!row) {
1474
+ return fail(404, {
1475
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1476
+ hash: oldHash,
1477
+ usage: [],
1478
+ foundIn: 0,
1479
+ });
1480
+ }
1481
+ // Media-enabled guard only: replace does no R2 write (the new bytes are already stored, the old
1482
+ // bytes are kept), so there is no bucket binding to resolve. Media-off still refuses before any
1483
+ // git write.
1484
+ if (!runtime.resolvedAssets.enabled) {
1485
+ return fail(503, { error: 'Media is not enabled for this site.', hash: oldHash, usage: [], foundIn: 0 });
1486
+ }
1487
+ // Re-derive the plan from a FRESH content-manifest read (never trust a client plan). The planner
1488
+ // runs strict, so an unverifiable branch read throws; catch it and fail the replace closed (commit
1489
+ // nothing) rather than rewriting a partial set of references. The repointed token keeps the OLD
1490
+ // asset's slug (server-authoritative `row.slug`): a replace changes only the content hash, so the
1491
+ // name in every reference stays the same (the new bytes resolve by hash regardless of the slug).
1492
+ const newToken = replacementToken(row.slug, record.hash);
1493
+ let plan;
1494
+ try {
1495
+ plan = await planMediaRewrite({
1496
+ backend: runtime.backend,
1497
+ token,
1498
+ concepts: runtime.concepts,
1499
+ contentManifest: await readManifest(token),
1500
+ hash: oldHash,
1501
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1502
+ });
1503
+ }
1504
+ catch {
1505
+ return fail(503, {
1506
+ error: 'Could not verify where this asset is used. Try again.',
1507
+ hash: oldHash,
1508
+ usage: [],
1509
+ foundIn: 0,
1510
+ });
1511
+ }
1512
+ // The typed-slug gate, ALWAYS required for replace. A blank stored slug can never be satisfied by
1513
+ // the empty default, so it is treated as never-confirmed (the confirm cannot be bypassed).
1514
+ if (row.slug === '' || confirmSlug !== row.slug) {
1515
+ log.warn('media.replace_blocked', { editor: editor.email, hash: oldHash, foundIn: plan.affectedCount });
1516
+ return fail(409, {
1517
+ error: `Type ${row.slug} to confirm replacing it in ${plan.affectedCount} ${plan.affectedCount === 1 ? 'entry' : 'entries'}.`,
1518
+ hash: oldHash,
1519
+ usage: [],
1520
+ foundIn: plan.affectedCount,
1521
+ });
1522
+ }
1523
+ // Commit atomically: every rewritten entry plus the new media.json row (the OLD row stays, so the
1524
+ // old bytes keep a row). One commit, the same conflict handling as delete.
1525
+ const changes = plan.entries.map((e) => ({ path: e.path, content: e.newMarkdown }));
1526
+ changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
1527
+ const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
1528
+ try {
1529
+ await commitFiles(runtime.backend, changes, { message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1530
+ log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
1531
+ }
1532
+ catch (err) {
1533
+ commitFailure(commitFields, err, '/admin/media', 'The site changed since you opened it. Reload and try again.');
1534
+ }
1535
+ throw redirect(303, '/admin/media?replaced=1');
1536
+ }
1537
+ /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
1538
+ * asset's default alt across every published main entry that references it, bucketing each placement
1539
+ * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
1540
+ * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
1541
+ * The plan runs strict (fail-closed): an unverifiable usage read returns a 503 rather than a partial
1542
+ * plan, so the dialog never shows a count it cannot stand behind.
1543
+ *
1544
+ * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
1545
+ * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
1546
+ * ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
1547
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
1548
+ async function mediaAltPreview(event) {
1549
+ // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
1550
+ // replace-preview actions. A failed check refuses before the session read or any GitHub call.
1551
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1552
+ return fail(403, { error: 'csrf' });
1553
+ }
1554
+ requireSession(event);
1555
+ let payload;
1556
+ try {
1557
+ payload = JSON.parse(await event.request.text());
1558
+ }
1559
+ catch {
1560
+ return fail(400, { error: 'Could not read the request.' });
1561
+ }
1562
+ const hash = String(payload.hash ?? '');
1563
+ if (!MEDIA_HASH_RE.test(hash)) {
1564
+ return fail(400, { error: 'Invalid media hash.' });
1565
+ }
1566
+ const token = await mintToken(event.platform?.env ?? {});
1567
+ // The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
1568
+ // asset with no committed row has no default alt to push, so refuse.
1569
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1570
+ const row = mediaManifest[hash];
1571
+ if (!row) {
1572
+ return fail(404, { error: 'That asset is not committed.' });
1573
+ }
1574
+ // Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
1575
+ // it and fail closed, the same posture replace and delete take.
1576
+ const contentManifest = await readManifest(token);
1577
+ let plan;
1578
+ try {
1579
+ plan = await planMediaRewrite({
1580
+ backend: runtime.backend,
1581
+ token,
1582
+ concepts: runtime.concepts,
1583
+ contentManifest,
1584
+ hash,
1585
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite: false }),
1586
+ });
1587
+ }
1588
+ catch {
1589
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' });
1590
+ }
1591
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
1592
+ // carries neither), and aggregate the bucket counts across every placement.
1593
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
1594
+ const counts = { willFill: 0, customized: 0, decorativeSkipped: 0 };
1595
+ const entries = plan.entries.map((e) => {
1596
+ for (const p of e.placements) {
1597
+ if (p.bucket === 'will-fill')
1598
+ counts.willFill += 1;
1599
+ else if (p.bucket === 'customized')
1600
+ counts.customized += 1;
1601
+ else
1602
+ counts.decorativeSkipped += 1;
1603
+ }
1604
+ const manifestRow = byKey.get(`${e.concept}/${e.id}`);
1605
+ return {
1606
+ concept: e.concept,
1607
+ id: e.id,
1608
+ title: manifestRow?.title ?? e.id,
1609
+ permalink: manifestRow?.permalink,
1610
+ placements: e.placements,
1611
+ };
1612
+ });
1613
+ return { entries, branchDelta: plan.branchDelta, counts };
1614
+ }
1615
+ /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
1616
+ * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
1617
+ * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
1618
+ * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
1619
+ * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
1620
+ * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
1621
+ * that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
1622
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
1623
+ async function mediaAltApply(event) {
1624
+ const editor = requireSession(event);
1625
+ const token = await mintToken(event.platform?.env ?? {});
1626
+ const form = await event.request.formData();
1627
+ const hash = String(form.get('hash') ?? '');
1628
+ if (!MEDIA_HASH_RE.test(hash))
1629
+ throw error(400, 'Invalid media hash');
1630
+ // The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
1631
+ const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
1632
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1633
+ const row = mediaManifest[hash];
1634
+ if (!row) {
1635
+ return fail(404, { error: 'That asset is not committed.' });
1636
+ }
1637
+ // Media-enabled guard only: alt fill does no R2 write, so there is no bucket binding to resolve.
1638
+ if (!runtime.resolvedAssets.enabled) {
1639
+ return fail(503, { error: 'Media is not enabled for this site.' });
1640
+ }
1641
+ // Re-derive from a FRESH content-manifest read with the actual overwrite choice. Strict, so an
1642
+ // unverifiable branch read throws; catch it and fail closed (commit nothing).
1643
+ let plan;
1644
+ try {
1645
+ plan = await planMediaRewrite({
1646
+ backend: runtime.backend,
1647
+ token,
1648
+ concepts: runtime.concepts,
1649
+ contentManifest: await readManifest(token),
1650
+ hash,
1651
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
1652
+ });
1653
+ }
1654
+ catch {
1655
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' });
1656
+ }
1657
+ // Commit only the entries the transform actually changed. A reported-but-unchanged placement (a
1658
+ // kept custom alt, a decorative hero) has after === before, so an entry with only those is a no-op
1659
+ // and is excluded. Nothing changed at all is a successful no-op: skip the commit, still redirect.
1660
+ const changed = plan.entries.filter((e) => e.placements.some((p) => p.after !== p.before));
1661
+ if (changed.length === 0)
1662
+ throw redirect(303, '/admin/media?altPropagated=1');
1663
+ const changes = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
1664
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1665
+ try {
1666
+ await commitFiles(runtime.backend, changes, { message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1667
+ log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
1668
+ }
1669
+ catch (err) {
1670
+ commitFailure(commitFields, err, '/admin/media', 'The site changed since you opened it. Reload and try again.');
1671
+ }
1672
+ throw redirect(303, '/admin/media?altPropagated=1');
1673
+ }
1674
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
1104
1675
  }
1105
1676
  /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1106
1677
  * so a generous cap rejects only abuse-scale input. */
@@ -3,7 +3,7 @@ export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './a
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
5
  export { createMediaRoute } from './media-route.js';
6
- export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
6
+ export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, MediaReplaceFailure, MediaAltPropagateFailure, MediaBulkFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
7
7
  export { createNavRoutes } from './nav-routes.js';
8
8
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
9
9
  export { parseAdminPath, type AdminView } from './admin-dispatch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [