@glw907/cairn-cms 0.58.0 → 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.
- package/CHANGELOG.md +37 -0
- package/dist/components/CairnMediaLibrary.svelte +1101 -27
- package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
- package/dist/components/admin-icons.d.ts +1 -0
- package/dist/components/admin-icons.js +1 -0
- package/dist/components/cairn-admin.css +147 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/bulk-delete-plan.d.ts +24 -0
- package/dist/media/bulk-delete-plan.js +25 -0
- package/dist/media/orphan-scan.d.ts +37 -0
- package/dist/media/orphan-scan.js +42 -0
- package/dist/media/reconcile.d.ts +3 -0
- package/dist/media/reconcile.js +3 -2
- package/dist/sveltekit/cairn-admin.d.ts +3 -0
- package/dist/sveltekit/cairn-admin.js +6 -0
- package/dist/sveltekit/content-routes.d.ts +37 -4
- package/dist/sveltekit/content-routes.js +247 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +1101 -27
- package/src/lib/components/admin-icons.ts +1 -0
- package/src/lib/log/events.ts +2 -0
- package/src/lib/media/bulk-delete-plan.ts +54 -0
- package/src/lib/media/orphan-scan.ts +74 -0
- package/src/lib/media/reconcile.ts +3 -2
- package/src/lib/sveltekit/cairn-admin.ts +6 -0
- package/src/lib/sveltekit/content-routes.ts +293 -5
- package/src/lib/sveltekit/index.ts +1 -0
|
@@ -29,3 +29,4 @@ export { default as RefreshCwIcon } from '@lucide/svelte/icons/refresh-cw';
|
|
|
29
29
|
export { default as GitBranchIcon } from '@lucide/svelte/icons/git-branch';
|
|
30
30
|
export { default as ArrowRightIcon } from '@lucide/svelte/icons/arrow-right';
|
|
31
31
|
export { default as MegaphoneIcon } from '@lucide/svelte/icons/megaphone';
|
|
32
|
+
export { default as DatabaseIcon } from '@lucide/svelte/icons/database';
|
package/src/lib/log/events.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// cairn-cms: the pure core of the bulk-delete safety floor. Given a STRICT usage index, the selected
|
|
2
|
+
// hashes, and the media manifest, it partitions the selection into what is safe to delete and what is
|
|
3
|
+
// skipped, with the reason. The gate is membership in the passed strict index, never a display count:
|
|
4
|
+
// the caller builds the index with strict:true (see usage.ts) so a transient branch-read failure
|
|
5
|
+
// fails the whole build rather than making a still-referenced asset look orphaned. This function
|
|
6
|
+
// stays pure so the same verdict is testable without a repo round trip and so the destructive action
|
|
7
|
+
// that consumes it can be reviewed against a fixed input.
|
|
8
|
+
import type { UsageEntry, UsageIndex } from './usage.js';
|
|
9
|
+
import type { MediaManifest } from './manifest.js';
|
|
10
|
+
|
|
11
|
+
/** One selected hash that is not deleted, with why and (for the where-used) its usage rows. The rows
|
|
12
|
+
* are present only for 'still-referenced'; an 'uncommitted' skip carries an empty list. */
|
|
13
|
+
export interface BulkDeleteSkip {
|
|
14
|
+
hash: string;
|
|
15
|
+
reason: 'still-referenced' | 'uncommitted';
|
|
16
|
+
usage: UsageEntry[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The partitioned selection: the hashes safe to purge and the hashes held back. Both arrays keep the
|
|
20
|
+
* input order of `selected` so the screen reports them in the order the user picked. */
|
|
21
|
+
export interface BulkDeletePlan {
|
|
22
|
+
deletable: string[];
|
|
23
|
+
skipped: BulkDeleteSkip[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Partition `selected` against a strict usage index and the media manifest.
|
|
28
|
+
*
|
|
29
|
+
* A hash with one or more usage rows is skipped 'still-referenced', carrying those rows for the
|
|
30
|
+
* where-used. A hash with no usage row and no committed manifest row is skipped 'uncommitted', since
|
|
31
|
+
* there is nothing committed to delete. A hash with no usage row and a committed manifest row is
|
|
32
|
+
* deletable. The input order of `selected` is preserved in both output arrays.
|
|
33
|
+
*/
|
|
34
|
+
export function planBulkDelete(
|
|
35
|
+
selected: string[],
|
|
36
|
+
index: UsageIndex,
|
|
37
|
+
manifest: MediaManifest,
|
|
38
|
+
): BulkDeletePlan {
|
|
39
|
+
const deletable: string[] = [];
|
|
40
|
+
const skipped: BulkDeleteSkip[] = [];
|
|
41
|
+
|
|
42
|
+
for (const hash of selected) {
|
|
43
|
+
const usage = index.get(hash);
|
|
44
|
+
if (usage && usage.length > 0) {
|
|
45
|
+
skipped.push({ hash, reason: 'still-referenced', usage });
|
|
46
|
+
} else if (manifest[hash]) {
|
|
47
|
+
deletable.push(hash);
|
|
48
|
+
} else {
|
|
49
|
+
skipped.push({ hash, reason: 'uncommitted', usage: [] });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { deletable, skipped };
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// cairn-cms: the orphan-scan projection, the pure model behind the admin Media Library's scan
|
|
2
|
+
// surface. It folds reconcileMedia's two directions together with the usage index into the two rows
|
|
3
|
+
// the screen renders: the purgeable byte-rows and the read-only broken-reference rows (manifest rows
|
|
4
|
+
// whose bytes are gone). It only projects; no path here reads R2, the manifest, or git. The module
|
|
5
|
+
// is engine-internal and on no public subpath.
|
|
6
|
+
//
|
|
7
|
+
// An orphaned byte is a stored R2 object whose hash has NO manifest row AND appears in NO usage row,
|
|
8
|
+
// so it is referenced nowhere across main and every open branch. Reconcile only checks main's
|
|
9
|
+
// manifest, so a branch-only upload (bytes in R2, manifest row only on the open cairn/* branch) gets
|
|
10
|
+
// flagged as an orphaned object even though a colleague's in-progress draft references it. The byte
|
|
11
|
+
// purge is irreversible, so we intersect reconcile's verdict with the strict cross-branch usage
|
|
12
|
+
// index here: any hash the index references is in use and is dropped from orphanedBytes, which keeps
|
|
13
|
+
// a live draft's bytes from ever reaching the purge surface.
|
|
14
|
+
import { MEDIA_KEY_RE, type ReconcileResult } from './reconcile.js';
|
|
15
|
+
import type { MediaManifest } from './manifest.js';
|
|
16
|
+
import type { UsageEntry, UsageIndex } from './usage.js';
|
|
17
|
+
|
|
18
|
+
/** A purgeable orphan: a stored R2 key with no manifest row, plus the 16-hex hash parsed from it. */
|
|
19
|
+
export interface OrphanByteRow {
|
|
20
|
+
/** The full R2 object key, e.g. "media/ff/ffffffffffffffff.webp". */
|
|
21
|
+
key: string;
|
|
22
|
+
/** The 16-hex content hash parsed from the key. */
|
|
23
|
+
hash: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A broken reference: a manifest row whose bytes are gone. Read-only, since purging it would drop a
|
|
27
|
+
* still-referenced asset's record; the screen shows where it is used so an operator can re-ingest. */
|
|
28
|
+
export interface BrokenRefRow {
|
|
29
|
+
/** The 16-hex content hash of the manifest row whose bytes are missing. */
|
|
30
|
+
hash: string;
|
|
31
|
+
/** The manifest row's display slug, or '' when the row is somehow absent. */
|
|
32
|
+
slug: string;
|
|
33
|
+
/** Where the asset is referenced, from the usage index. Empty when no reference was found. */
|
|
34
|
+
usage: UsageEntry[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The scan surface model: the two row sets the Library renders. */
|
|
38
|
+
export interface OrphanScan {
|
|
39
|
+
orphanedBytes: OrphanByteRow[];
|
|
40
|
+
brokenRefs: BrokenRefRow[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Project a reconcile read plus the usage index into the scan surface model.
|
|
45
|
+
*
|
|
46
|
+
* `orphanedBytes` come from `reconcile.orphanedObjects`: each key is parsed to its hash via the
|
|
47
|
+
* shared media-key grammar, and a key that does not match (so it is not a content-addressed media
|
|
48
|
+
* object) is skipped. A key whose hash the usage index references is also skipped: it is referenced
|
|
49
|
+
* on main or some open branch, so its bytes are in use, not orphaned. `brokenRefs` come from
|
|
50
|
+
* `reconcile.missingObjects`: each hash carries its
|
|
51
|
+
* manifest slug (falling back to '' when the row is absent) and its where-used rows from the index
|
|
52
|
+
* (an empty list when no reference was found). Both directions keep their input order.
|
|
53
|
+
*/
|
|
54
|
+
export function buildOrphanScan(
|
|
55
|
+
reconcile: ReconcileResult,
|
|
56
|
+
manifest: MediaManifest,
|
|
57
|
+
index: UsageIndex,
|
|
58
|
+
): OrphanScan {
|
|
59
|
+
const orphanedBytes: OrphanByteRow[] = [];
|
|
60
|
+
for (const key of reconcile.orphanedObjects) {
|
|
61
|
+
const hash = MEDIA_KEY_RE.exec(key)?.[1];
|
|
62
|
+
if (hash === undefined) continue;
|
|
63
|
+
if (index.has(hash)) continue;
|
|
64
|
+
orphanedBytes.push({ key, hash });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const brokenRefs: BrokenRefRow[] = reconcile.missingObjects.map((hash) => ({
|
|
68
|
+
hash,
|
|
69
|
+
slug: manifest[hash]?.slug ?? '',
|
|
70
|
+
usage: index.get(hash) ?? [],
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
return { orphanedBytes, brokenRefs };
|
|
74
|
+
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import type { MediaManifest } from './manifest.js';
|
|
9
9
|
import { log } from '../log/index.js';
|
|
10
10
|
|
|
11
|
-
/** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`.
|
|
12
|
-
|
|
11
|
+
/** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
|
|
12
|
+
* the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
|
|
13
|
+
export const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
|
|
13
14
|
|
|
14
15
|
/** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
|
|
15
16
|
* has no manifest row; `missingObjects` are manifest hashes with no stored object. */
|
|
@@ -186,6 +186,12 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
186
186
|
mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
|
|
187
187
|
mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
|
|
188
188
|
mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
|
|
189
|
+
// Pass C library actions: a multi-select bulk delete, the on-demand orphan scan, and the
|
|
190
|
+
// irreversible byte purge. The component posts to `?/mediaBulkDelete`, `?/mediaOrphanScan`, and
|
|
191
|
+
// `?/mediaPurge` (the purge key is short of its content method name). All gate on the media view.
|
|
192
|
+
mediaBulkDelete: viewAction(['media'], (event) => content.mediaBulkDelete(contentEvent(event, {}))),
|
|
193
|
+
mediaOrphanScan: viewAction(['media'], (event) => content.mediaOrphanScan(contentEvent(event, {}))),
|
|
194
|
+
mediaPurge: viewAction(['media'], (event) => content.mediaPurgeOrphans(contentEvent(event, {}))),
|
|
189
195
|
publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
|
|
190
196
|
addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
|
|
191
197
|
removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
|
|
@@ -29,10 +29,14 @@ import { mediaLibraryEntry } from '../media/library-entry.js';
|
|
|
29
29
|
import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
|
|
30
30
|
import { buildUsageIndex } from '../media/usage.js';
|
|
31
31
|
import type { UsageEntry } from '../media/usage.js';
|
|
32
|
+
import { runReconcile, MEDIA_KEY_RE, type ReconcileBucket } from '../media/reconcile.js';
|
|
33
|
+
import { buildOrphanScan, type OrphanScan } from '../media/orphan-scan.js';
|
|
32
34
|
import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
|
|
33
35
|
import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
|
|
34
36
|
import { planMediaRewrite } from '../media/rewrite-plan.js';
|
|
35
37
|
import type { BranchRef } from '../media/rewrite-plan.js';
|
|
38
|
+
import { planBulkDelete } from '../media/bulk-delete-plan.js';
|
|
39
|
+
import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
|
|
36
40
|
import type { CookieJar, EventBase } from './types.js';
|
|
37
41
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
|
|
38
42
|
import type { Editor, Role } from '../auth/types.js';
|
|
@@ -161,9 +165,10 @@ export interface MediaLibraryData {
|
|
|
161
165
|
* redirected commit conflict never overwrite each other. */
|
|
162
166
|
error: string | null;
|
|
163
167
|
/** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
|
|
164
|
-
* `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
|
|
165
|
-
*
|
|
166
|
-
|
|
168
|
+
* `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
|
|
169
|
+
* `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
|
|
170
|
+
* The component renders a polite success strip for each. */
|
|
171
|
+
flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
|
|
167
172
|
/** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
|
|
168
173
|
* its own slot rather than the degraded-load `error` above, so the two never collide. */
|
|
169
174
|
flashError: string | null;
|
|
@@ -248,6 +253,30 @@ export interface MediaAltPropagateFailure {
|
|
|
248
253
|
error: string;
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
/** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
|
|
257
|
+
* (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
|
|
258
|
+
* returned summary, not a fail. */
|
|
259
|
+
export interface MediaBulkFailure {
|
|
260
|
+
error: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
|
|
264
|
+
* partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
|
|
265
|
+
* not on the package subpath, so no reference page. */
|
|
266
|
+
export interface MediaBulkDeleteResult {
|
|
267
|
+
deleted: string[];
|
|
268
|
+
skipped: BulkDeleteSkip[];
|
|
269
|
+
failed: { hash: string; error: string }[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
|
|
273
|
+
* manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
|
|
274
|
+
export interface MediaOrphanPurgeResult {
|
|
275
|
+
purged: string[];
|
|
276
|
+
skippedClaimed: string[];
|
|
277
|
+
failed: { key: string; error: string }[];
|
|
278
|
+
}
|
|
279
|
+
|
|
251
280
|
/** One entry the replace preview will rewrite, enriched with its display title and permalink from the
|
|
252
281
|
* content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
|
|
253
282
|
* confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
|
|
@@ -312,7 +341,7 @@ export interface MediaAltPreviewPlan {
|
|
|
312
341
|
* `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
|
|
313
342
|
* refusal without a second type. */
|
|
314
343
|
export type ContentFormFailure = Partial<
|
|
315
|
-
SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure
|
|
344
|
+
SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure
|
|
316
345
|
>;
|
|
317
346
|
|
|
318
347
|
/** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
|
|
@@ -546,6 +575,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
546
575
|
else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
|
|
547
576
|
else if (event.url.searchParams.get('replaced') === '1') flash = 'replaced';
|
|
548
577
|
else if (event.url.searchParams.get('altPropagated') === '1') flash = 'altPropagated';
|
|
578
|
+
else if (event.url.searchParams.get('bulkDeleted') === '1') flash = 'bulkDeleted';
|
|
579
|
+
else if (event.url.searchParams.get('orphansPurged') === '1') flash = 'orphansPurged';
|
|
549
580
|
const flashError = event.url.searchParams.get('error');
|
|
550
581
|
let token: string;
|
|
551
582
|
try {
|
|
@@ -1493,6 +1524,263 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1493
1524
|
throw redirect(303, '/admin/media?deleted=1');
|
|
1494
1525
|
}
|
|
1495
1526
|
|
|
1527
|
+
/** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
|
|
1528
|
+
* many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
|
|
1529
|
+
* shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
|
|
1530
|
+
* would blow the workerd connection budget at many open branches). The fail-closed posture is for
|
|
1531
|
+
* the WHOLE batch: if that single strict index cannot complete, the action refuses everything and
|
|
1532
|
+
* commits nothing, rather than risk deleting bytes a branch still references.
|
|
1533
|
+
*
|
|
1534
|
+
* Skip-and-report, never force: the pure planBulkDelete partitions the selection against the strict
|
|
1535
|
+
* index into deletable (no usage row, a committed manifest row exists), skipped-still-referenced (a
|
|
1536
|
+
* usage row, carried for the where-used), and skipped-uncommitted (no manifest row). An in-use item
|
|
1537
|
+
* is skipped and reported, never bulk-force-deleted; forced in-use deletion stays the single-item
|
|
1538
|
+
* typed-slug path.
|
|
1539
|
+
*
|
|
1540
|
+
* The order is load-bearing, mirroring single delete: ONE atomic commit removes every deletable row
|
|
1541
|
+
* FIRST, then the R2 objects are deleted (commit-row-then-delete-R2). A failure after the commit
|
|
1542
|
+
* leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
|
|
1543
|
+
* delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
|
|
1544
|
+
* aborts the rest of the batch. The result is an itemized 207-style summary the component renders
|
|
1545
|
+
* (deleted / skipped with reasons / failed); there is no success redirect. */
|
|
1546
|
+
async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
|
|
1547
|
+
const editor = requireSession(event);
|
|
1548
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1549
|
+
|
|
1550
|
+
// Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
|
|
1551
|
+
// `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
|
|
1552
|
+
// dropped silently rather than surfaced as a skip (it was never a real selection).
|
|
1553
|
+
const form = await event.request.formData();
|
|
1554
|
+
let raw = form.getAll('hash').map(String);
|
|
1555
|
+
if (raw.length === 0) {
|
|
1556
|
+
const json = form.get('hashes');
|
|
1557
|
+
if (typeof json === 'string') {
|
|
1558
|
+
try {
|
|
1559
|
+
const parsed: unknown = JSON.parse(json);
|
|
1560
|
+
if (Array.isArray(parsed)) raw = parsed.map(String);
|
|
1561
|
+
} catch {
|
|
1562
|
+
raw = [];
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
|
|
1567
|
+
|
|
1568
|
+
// Read the fresh media manifest (the deletable rows come from here, by hash).
|
|
1569
|
+
const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1570
|
+
|
|
1571
|
+
// Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
|
|
1572
|
+
// the commit, exactly like single delete.
|
|
1573
|
+
const resolved = runtime.resolvedAssets;
|
|
1574
|
+
if (!resolved.enabled) {
|
|
1575
|
+
return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
|
|
1576
|
+
}
|
|
1577
|
+
const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
|
|
1578
|
+
const rawBucket = platformEnv[resolved.bucketBinding];
|
|
1579
|
+
if (!rawBucket) {
|
|
1580
|
+
return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
|
|
1581
|
+
}
|
|
1582
|
+
const store = r2Store(rawBucket as R2Bucket);
|
|
1583
|
+
|
|
1584
|
+
// THE fail-closed gate for the whole batch: one shared strict usage index. STRICT mode rethrows a
|
|
1585
|
+
// branch-read failure, so a transient branch read failing refuses the whole batch rather than
|
|
1586
|
+
// mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
|
|
1587
|
+
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
1588
|
+
try {
|
|
1589
|
+
index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
|
|
1590
|
+
} catch {
|
|
1591
|
+
return fail(503, { error: 'Could not verify where these assets are used. Try again.' } satisfies MediaBulkFailure);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// The pure partition: membership in the fresh strict index is the gate, never the display count.
|
|
1595
|
+
const plan = planBulkDelete(selected, index, manifest);
|
|
1596
|
+
// An all-skipped or empty batch is a no-op success: nothing committed, nothing deleted.
|
|
1597
|
+
if (plan.deletable.length === 0) {
|
|
1598
|
+
return { deleted: [], skipped: plan.skipped, failed: [] } satisfies MediaBulkDeleteResult;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// ONE atomic commit removing EVERY deletable row, folded over removeMediaEntry.
|
|
1602
|
+
let next = manifest;
|
|
1603
|
+
for (const hash of plan.deletable) next = removeMediaEntry(next, hash);
|
|
1604
|
+
const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
|
|
1605
|
+
try {
|
|
1606
|
+
await commitFiles(
|
|
1607
|
+
runtime.backend,
|
|
1608
|
+
[{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }],
|
|
1609
|
+
{ message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } },
|
|
1610
|
+
token,
|
|
1611
|
+
);
|
|
1612
|
+
log.info('commit.succeeded', commitFields);
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
commitFailure(commitFields, err, '/admin/media',
|
|
1615
|
+
'The media manifest changed since you opened it. Reload and try again.');
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// THEN delete each deletable hash's R2 object (the load-bearing order, see the docstring). Best
|
|
1619
|
+
// effort and batch-resilient: a thrown key derivation or a delete error is reported in `failed`
|
|
1620
|
+
// and the loop continues. An absent object is a no-op (the R2 contract).
|
|
1621
|
+
const deleted: string[] = [];
|
|
1622
|
+
const failed: { hash: string; error: string }[] = [];
|
|
1623
|
+
for (const hash of plan.deletable) {
|
|
1624
|
+
try {
|
|
1625
|
+
const row = manifest[hash];
|
|
1626
|
+
await store.delete(r2Key(row.hash, row.ext));
|
|
1627
|
+
deleted.push(hash);
|
|
1628
|
+
} catch (err) {
|
|
1629
|
+
failed.push({ hash, error: err instanceof Error ? err.message : String(err) });
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
|
|
1634
|
+
return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
|
|
1638
|
+
* with one strict cross-branch usage index for the broken-reference where-used. It runs only when
|
|
1639
|
+
* requested, never on the loaded index, because it is heavier than the load path: a full R2 list
|
|
1640
|
+
* plus a reconcile pass on top of the strict usage build.
|
|
1641
|
+
*
|
|
1642
|
+
* Detection-time fail-closed: BOTH the reconcile and the strict usage build run inside one
|
|
1643
|
+
* try/catch, and any throw refuses the whole scan with fail(503) rather than returning a partial
|
|
1644
|
+
* result. The reconcile must not run on a half-listed bucket: a truncated R2 list would call
|
|
1645
|
+
* still-stored bytes orphaned. The strict usage build must not run on a half-read branch set: an
|
|
1646
|
+
* unread branch would make a branch-referenced asset look orphaned. A wrong orphan verdict here
|
|
1647
|
+
* feeds the irreversible purge, so the scan refuses rather than risk it.
|
|
1648
|
+
*
|
|
1649
|
+
* The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
|
|
1650
|
+
* purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
|
|
1651
|
+
* where-used so an operator can re-ingest rather than purge a still-referenced record). */
|
|
1652
|
+
async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
|
|
1653
|
+
requireSession(event);
|
|
1654
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1655
|
+
|
|
1656
|
+
// Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
|
|
1657
|
+
// the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
|
|
1658
|
+
const resolved = runtime.resolvedAssets;
|
|
1659
|
+
if (!resolved.enabled) {
|
|
1660
|
+
return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
|
|
1661
|
+
}
|
|
1662
|
+
const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
|
|
1663
|
+
const rawBucket = platformEnv[resolved.bucketBinding];
|
|
1664
|
+
if (!rawBucket) {
|
|
1665
|
+
return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Read the fresh media manifest for the reconcile's manifest side.
|
|
1669
|
+
const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1670
|
+
|
|
1671
|
+
// THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
|
|
1672
|
+
// and the strict usage build (a branch read that must complete in full) are both unsafe to use
|
|
1673
|
+
// partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
|
|
1674
|
+
// would feed the irreversible purge.
|
|
1675
|
+
let reconcile: Awaited<ReturnType<typeof runReconcile>>;
|
|
1676
|
+
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
1677
|
+
try {
|
|
1678
|
+
reconcile = await runReconcile(rawBucket as unknown as ReconcileBucket, manifest);
|
|
1679
|
+
index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
|
|
1680
|
+
} catch {
|
|
1681
|
+
return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' } satisfies MediaBulkFailure);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
return buildOrphanScan(reconcile, manifest, index);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
|
|
1688
|
+
* in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
|
|
1689
|
+
* history. The whole action is built around that fact.
|
|
1690
|
+
*
|
|
1691
|
+
* The typed-count confirm is the never-bypassable gate, the analogue of single delete's typed-slug
|
|
1692
|
+
* check. The form's `confirm` must equal the count of selected keys (the approved rev.2 mockup's
|
|
1693
|
+
* "Type N to purge these files for good"); an empty selection or a mismatched count deletes nothing.
|
|
1694
|
+
*
|
|
1695
|
+
* Re-derive fresh is the safety crux. The selection came from an earlier scan, so the action does
|
|
1696
|
+
* NOT trust it: the purge keys are client-posted, so the server cannot assume they came from a fresh
|
|
1697
|
+
* scan. It reads the current media manifest AND rebuilds ONE strict cross-branch usage index, then
|
|
1698
|
+
* for each selected key parses the hash from the key grammar. A key that does not match the grammar
|
|
1699
|
+
* was never a real orphan key and is dropped silently. A key whose hash now has a manifest row OR is
|
|
1700
|
+
* referenced on any open cairn/* branch survived the scan window (it was claimed by a row, or a
|
|
1701
|
+
* draft started referencing those bytes), so it is skipped into skippedClaimed and its bytes survive.
|
|
1702
|
+
* Only a key whose hash is STILL absent from both is purged. This closes the TOCTOU between scan and
|
|
1703
|
+
* purge that could otherwise irreversibly delete a live draft's bytes.
|
|
1704
|
+
*
|
|
1705
|
+
* Like the scan and the bulk delete, the strict index build is the fail-closed gate: a branch read
|
|
1706
|
+
* that throws refuses the whole batch with fail(503) rather than mistaking an unverifiable reference
|
|
1707
|
+
* for an absent one. The index is built exactly once for the batch, never once per key.
|
|
1708
|
+
*
|
|
1709
|
+
* There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
|
|
1710
|
+
* the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
|
|
1711
|
+
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
|
|
1712
|
+
async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
|
|
1713
|
+
const editor = requireSession(event);
|
|
1714
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1715
|
+
|
|
1716
|
+
// Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
|
|
1717
|
+
// deletes through the MediaStore seam, so wrap the raw binding.
|
|
1718
|
+
const resolved = runtime.resolvedAssets;
|
|
1719
|
+
if (!resolved.enabled) {
|
|
1720
|
+
return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
|
|
1721
|
+
}
|
|
1722
|
+
const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
|
|
1723
|
+
const rawBucket = platformEnv[resolved.bucketBinding];
|
|
1724
|
+
if (!rawBucket) {
|
|
1725
|
+
return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
|
|
1726
|
+
}
|
|
1727
|
+
const store = r2Store(rawBucket as R2Bucket);
|
|
1728
|
+
|
|
1729
|
+
// Read the selected R2 keys and the typed confirm.
|
|
1730
|
+
const form = await event.request.formData();
|
|
1731
|
+
const keys = form.getAll('key').map(String);
|
|
1732
|
+
const confirm = String(form.get('confirm') ?? '');
|
|
1733
|
+
|
|
1734
|
+
// The irreversible gate: the confirm must equal the selected count, and the set must be non-empty.
|
|
1735
|
+
// A mismatch or an empty set refuses and deletes NOTHING.
|
|
1736
|
+
if (keys.length === 0 || confirm !== String(keys.length)) {
|
|
1737
|
+
return fail(400, { error: 'Type the number of files to confirm the purge.' } satisfies MediaBulkFailure);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
|
|
1741
|
+
const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1742
|
+
|
|
1743
|
+
// THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
|
|
1744
|
+
// with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
|
|
1745
|
+
// branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
|
|
1746
|
+
// treated as a true orphan. Build exactly one index, never one per key.
|
|
1747
|
+
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
1748
|
+
try {
|
|
1749
|
+
index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
|
|
1750
|
+
} catch {
|
|
1751
|
+
return fail(503, { error: 'Could not verify where these files are used. Try again.' } satisfies MediaBulkFailure);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const purged: string[] = [];
|
|
1755
|
+
const skippedClaimed: string[] = [];
|
|
1756
|
+
const failed: { key: string; error: string }[] = [];
|
|
1757
|
+
for (const key of keys) {
|
|
1758
|
+
const hash = MEDIA_KEY_RE.exec(key)?.[1];
|
|
1759
|
+
// A key that does not match the grammar was never a real orphan key: drop it silently.
|
|
1760
|
+
if (hash === undefined) continue;
|
|
1761
|
+
// A hash that now has a manifest row was claimed since the scan: its bytes are a live asset now.
|
|
1762
|
+
if (manifest[hash]) {
|
|
1763
|
+
skippedClaimed.push(key);
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
// A hash referenced on any open cairn/* branch backs an in-progress draft: skip it claimed too.
|
|
1767
|
+
if (index.has(hash)) {
|
|
1768
|
+
skippedClaimed.push(key);
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
// Still orphaned: delete the object directly. No commit, there is no manifest row.
|
|
1772
|
+
try {
|
|
1773
|
+
await store.delete(key);
|
|
1774
|
+
purged.push(key);
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
failed.push({ key, error: err instanceof Error ? err.message : String(err) });
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
|
|
1781
|
+
return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1496
1784
|
/** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
|
|
1497
1785
|
* row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
|
|
1498
1786
|
* rename never breaks an existing `media:` reference. The default alt is the asset's value for the
|
|
@@ -1881,7 +2169,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1881
2169
|
throw redirect(303, '/admin/media?altPropagated=1');
|
|
1882
2170
|
}
|
|
1883
2171
|
|
|
1884
|
-
return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
|
|
2172
|
+
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 };
|
|
1885
2173
|
}
|
|
1886
2174
|
|
|
1887
2175
|
/** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|