@glw907/cairn-cms 0.57.1 → 0.58.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 +29 -0
- package/dist/components/CairnMediaLibrary.svelte +978 -8
- package/dist/components/admin-icons.d.ts +4 -0
- package/dist/components/admin-icons.js +4 -0
- package/dist/components/cairn-admin.css +255 -3
- package/dist/content/media-rewrite.d.ts +65 -0
- package/dist/content/media-rewrite.js +442 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/rewrite-plan.d.ts +65 -0
- package/dist/media/rewrite-plan.js +61 -0
- package/dist/sveltekit/cairn-admin.d.ts +5 -0
- package/dist/sveltekit/cairn-admin.js +9 -0
- package/dist/sveltekit/content-routes.d.ts +85 -4
- package/dist/sveltekit/content-routes.js +326 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +978 -8
- package/src/lib/components/admin-icons.ts +4 -0
- package/src/lib/content/media-rewrite.ts +555 -0
- package/src/lib/log/events.ts +4 -1
- package/src/lib/media/rewrite-plan.ts +122 -0
- package/src/lib/sveltekit/cairn-admin.ts +9 -0
- package/src/lib/sveltekit/content-routes.ts +434 -5
- package/src/lib/sveltekit/index.ts +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// cairn-cms: the server-side media rewrite planner, the shared core behind the media-library bulk
|
|
2
|
+
// rewrite preview and apply (replace-in-place and fill-alt). Given an asset content hash and a per
|
|
3
|
+
// entry transform (the closure already carries the new token or the default alt), it resolves which
|
|
4
|
+
// published main entries reference the hash, runs the transform over each, and returns a preview
|
|
5
|
+
// plan: the affected entries with their rewritten markdown and per-placement diff, plus the affected
|
|
6
|
+
// count. It also returns a report-only cross-branch delta, the open cairn/* edit branches that
|
|
7
|
+
// reference the same bytes, so the screen can warn that an apply touches main only and the drafts
|
|
8
|
+
// keep their own copy of the reference until they publish.
|
|
9
|
+
//
|
|
10
|
+
// It is fail-closed. The usage read runs in strict mode, so a transient branch-read failure throws
|
|
11
|
+
// out of here rather than degrading to an "absent reference" the way the Library display tolerates.
|
|
12
|
+
// A planner that fed a partial usage view into an apply would rewrite some references and silently
|
|
13
|
+
// leave others, so it must reject instead. This is the same gate the 3c safe-delete uses.
|
|
14
|
+
//
|
|
15
|
+
// It lives in its own node-safe module (no @codemirror, no DOM, no @sveltejs/kit): the transform is
|
|
16
|
+
// injected, so the planner never imports the editor surface. It is internal, exported from no package
|
|
17
|
+
// subpath, so it carries no reference page.
|
|
18
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
19
|
+
import type { RepoRef } from '../github/types.js';
|
|
20
|
+
import type { Manifest } from '../content/manifest.js';
|
|
21
|
+
import { findConcept } from '../content/concepts.js';
|
|
22
|
+
import { filenameFromId } from '../content/ids.js';
|
|
23
|
+
import { readRaw } from '../github/repo.js';
|
|
24
|
+
import { buildUsageIndex } from './usage.js';
|
|
25
|
+
|
|
26
|
+
/** One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
|
|
27
|
+
* diff, and the rewritten markdown a later apply commits. `P` is the transform's placement type
|
|
28
|
+
* (a RepointPlacement for replace, an AltPlacement for fill-alt). */
|
|
29
|
+
export interface PlannedEntry<P = unknown> {
|
|
30
|
+
/** The concept id, e.g. "posts". */
|
|
31
|
+
concept: string;
|
|
32
|
+
/** The entry id (its filename stem). */
|
|
33
|
+
id: string;
|
|
34
|
+
/** The entry's repo path, `${concept.dir}/${filenameFromId(id)}`. */
|
|
35
|
+
path: string;
|
|
36
|
+
/** The transform's diff for this entry: one placement per rewritten reference. */
|
|
37
|
+
placements: P[];
|
|
38
|
+
/** The entry's markdown after the transform, byte-identical to the source apart from the rewrite. */
|
|
39
|
+
newMarkdown: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One open edit branch that also references the asset, with the entries on it. Report-only: an apply
|
|
43
|
+
* rewrites main, never a branch, so the screen surfaces these as a delta the editor handles by
|
|
44
|
+
* republishing the draft. */
|
|
45
|
+
export interface BranchRef {
|
|
46
|
+
/** The cairn/* branch name. */
|
|
47
|
+
branch: string;
|
|
48
|
+
/** The entries on that branch that reference the asset. */
|
|
49
|
+
entries: { concept: string; id: string }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The preview plan: the main entries to rewrite, the report-only branch delta, and the distinct
|
|
53
|
+
* count of affected main entries (the entries the transform actually changed). */
|
|
54
|
+
export interface RewritePlan<P = unknown> {
|
|
55
|
+
entries: PlannedEntry<P>[];
|
|
56
|
+
branchDelta: BranchRef[];
|
|
57
|
+
affectedCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
|
|
62
|
+
* unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
|
|
63
|
+
*
|
|
64
|
+
* - Published rows are the main work. Each entry's file is read in parallel and run through
|
|
65
|
+
* `args.transform`. An entry is included only when the transform reports at least one placement, so
|
|
66
|
+
* a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
|
|
67
|
+
* than committing an unchanged file. A row whose concept is not configured, or whose file read
|
|
68
|
+
* returns null (a stale manifest row), is skipped.
|
|
69
|
+
* - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
|
|
70
|
+
* never the published origin, so main never appears in the delta.
|
|
71
|
+
*
|
|
72
|
+
* `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
|
|
73
|
+
* planner does not read the media manifest: the transform closure already carries the new token or
|
|
74
|
+
* the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
|
|
75
|
+
* editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
|
|
76
|
+
*/
|
|
77
|
+
export async function planMediaRewrite<P = unknown>(args: {
|
|
78
|
+
backend: RepoRef;
|
|
79
|
+
token: string;
|
|
80
|
+
concepts: ConceptDescriptor[];
|
|
81
|
+
contentManifest: Manifest;
|
|
82
|
+
hash: string;
|
|
83
|
+
transform: (markdown: string) => { markdown: string; placements: P[] };
|
|
84
|
+
}): Promise<RewritePlan<P>> {
|
|
85
|
+
// Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
|
|
86
|
+
// Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
|
|
87
|
+
const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
|
|
88
|
+
strict: true,
|
|
89
|
+
});
|
|
90
|
+
const rows = index.get(args.hash) ?? [];
|
|
91
|
+
|
|
92
|
+
// The main arm: read each referencing published entry in parallel (one round-trip latency floor,
|
|
93
|
+
// mirroring buildUsageIndex's per-branch batch), run the transform, and keep only the entries it
|
|
94
|
+
// changed. A null is a row whose concept is not configured or whose file is absent: it is skipped.
|
|
95
|
+
const published = rows.filter((row) => row.origin.kind === 'published');
|
|
96
|
+
const planned = await Promise.all(
|
|
97
|
+
published.map(async (row): Promise<PlannedEntry<P> | null> => {
|
|
98
|
+
const concept = findConcept(args.concepts, row.concept);
|
|
99
|
+
if (!concept) return null;
|
|
100
|
+
const path = `${concept.dir}/${filenameFromId(row.id)}`;
|
|
101
|
+
const markdown = await readRaw(args.backend, path, args.token);
|
|
102
|
+
if (markdown === null) return null;
|
|
103
|
+
const result = args.transform(markdown);
|
|
104
|
+
if (result.placements.length === 0) return null;
|
|
105
|
+
return { concept: row.concept, id: row.id, path, placements: result.placements, newMarkdown: result.markdown };
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const entries = planned.filter((entry): entry is PlannedEntry<P> => entry !== null);
|
|
109
|
+
|
|
110
|
+
// The branch arm: group the branch rows by branch in first-seen order, preserving the row order the
|
|
111
|
+
// index emits within each group. Branch rows are never the published origin, so main never appears.
|
|
112
|
+
const byBranch = new Map<string, { concept: string; id: string }[]>();
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
if (row.origin.kind !== 'branch') continue;
|
|
115
|
+
const list = byBranch.get(row.origin.branch);
|
|
116
|
+
if (list) list.push({ concept: row.concept, id: row.id });
|
|
117
|
+
else byBranch.set(row.origin.branch, [{ concept: row.concept, id: row.id }]);
|
|
118
|
+
}
|
|
119
|
+
const branchDelta: BranchRef[] = [...byBranch].map(([branch, branchEntries]) => ({ branch, entries: branchEntries }));
|
|
120
|
+
|
|
121
|
+
return { entries, branchDelta, affectedCount: entries.length };
|
|
122
|
+
}
|
|
@@ -177,6 +177,15 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
177
177
|
),
|
|
178
178
|
mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
|
|
179
179
|
mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
|
|
180
|
+
// The Library is not entry-scoped, so a replace uploads its new file through the same content-
|
|
181
|
+
// addressed ingest mounted media-scoped (uploadAction reads no concept/id), then previews and
|
|
182
|
+
// applies the repoint. Alt propagation previews and applies the alt fill. The preview pair are 2a
|
|
183
|
+
// fetch actions; the apply pair are form posts. All gate on the media view.
|
|
184
|
+
mediaUpload: viewAction(['media'], (event) => content.uploadAction(contentEvent(event, {}))),
|
|
185
|
+
mediaReplacePreview: viewAction(['media'], (event) => content.mediaReplacePreview(contentEvent(event, {}))),
|
|
186
|
+
mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
|
|
187
|
+
mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
|
|
188
|
+
mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
|
|
180
189
|
publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
|
|
181
190
|
addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
|
|
182
191
|
removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
|
|
@@ -29,6 +29,10 @@ 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 { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
|
|
33
|
+
import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
|
|
34
|
+
import { planMediaRewrite } from '../media/rewrite-plan.js';
|
|
35
|
+
import type { BranchRef } from '../media/rewrite-plan.js';
|
|
32
36
|
import type { CookieJar, EventBase } from './types.js';
|
|
33
37
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
|
|
34
38
|
import type { Editor, Role } from '../auth/types.js';
|
|
@@ -157,8 +161,9 @@ export interface MediaLibraryData {
|
|
|
157
161
|
* redirected commit conflict never overwrite each other. */
|
|
158
162
|
error: string | null;
|
|
159
163
|
/** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
|
|
160
|
-
* `?updated=1`,
|
|
161
|
-
|
|
164
|
+
* `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`, null
|
|
165
|
+
* otherwise. The component renders a polite success strip for each. */
|
|
166
|
+
flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | null;
|
|
162
167
|
/** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
|
|
163
168
|
* its own slot rather than the degraded-load `error` above, so the two never collide. */
|
|
164
169
|
flashError: string | null;
|
|
@@ -226,12 +231,88 @@ export interface MediaUpdateFailure {
|
|
|
226
231
|
error: string;
|
|
227
232
|
}
|
|
228
233
|
|
|
234
|
+
/** A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
|
|
235
|
+
* typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
|
|
236
|
+
* bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count. */
|
|
237
|
+
export interface MediaReplaceFailure {
|
|
238
|
+
error: string;
|
|
239
|
+
hash: string;
|
|
240
|
+
usage: UsageEntry[];
|
|
241
|
+
foundIn: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
|
|
245
|
+
* open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
|
|
246
|
+
* typed-slug gate. */
|
|
247
|
+
export interface MediaAltPropagateFailure {
|
|
248
|
+
error: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One entry the replace preview will rewrite, enriched with its display title and permalink from the
|
|
252
|
+
* content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
|
|
253
|
+
* confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
|
|
254
|
+
* this. Admin-internal: exported from content-routes for the bundled Media Library component, not
|
|
255
|
+
* added to the package's sveltekit subpath, so it carries no reference page. */
|
|
256
|
+
export interface MediaReplacePreviewEntry {
|
|
257
|
+
/** The concept id, e.g. "posts". */
|
|
258
|
+
concept: string;
|
|
259
|
+
/** The entry id (its filename stem). */
|
|
260
|
+
id: string;
|
|
261
|
+
/** The entry's display title, from the content manifest. */
|
|
262
|
+
title: string;
|
|
263
|
+
/** The entry's public permalink, from the content manifest. */
|
|
264
|
+
permalink?: string;
|
|
265
|
+
/** The per-reference diff for this entry: one placement per repointed `media:` token. */
|
|
266
|
+
placements: RepointPlacement[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The replace preview plan: the affected main entries (enriched), the distinct affected count, and
|
|
270
|
+
* the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
|
|
271
|
+
* rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this. */
|
|
272
|
+
export interface MediaReplacePreviewPlan {
|
|
273
|
+
affectedCount: number;
|
|
274
|
+
entries: MediaReplacePreviewEntry[];
|
|
275
|
+
branchDelta: BranchRef[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One entry the alt-propagation preview reports, enriched with its display title and permalink from
|
|
279
|
+
* the content manifest. Its placements carry every reference of the asset on this entry, each tagged
|
|
280
|
+
* with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
|
|
281
|
+
* the screen can show what would change. Admin-internal: exported from content-routes for the bundled
|
|
282
|
+
* Media Library component, not added to the package's sveltekit subpath, so it carries no reference
|
|
283
|
+
* page. */
|
|
284
|
+
export interface MediaAltPreviewEntry {
|
|
285
|
+
/** The concept id, e.g. "posts". */
|
|
286
|
+
concept: string;
|
|
287
|
+
/** The entry id (its filename stem). */
|
|
288
|
+
id: string;
|
|
289
|
+
/** The entry's display title, from the content manifest. */
|
|
290
|
+
title: string;
|
|
291
|
+
/** The entry's public permalink, from the content manifest. */
|
|
292
|
+
permalink?: string;
|
|
293
|
+
/** The per-reference diff for this entry: one placement per reference of the asset. */
|
|
294
|
+
placements: AltPlacement[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
|
|
298
|
+
* cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
|
|
299
|
+
* apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
|
|
300
|
+
* only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
|
|
301
|
+
* can show every bucket; the apply commits only the entries it actually changes. */
|
|
302
|
+
export interface MediaAltPreviewPlan {
|
|
303
|
+
entries: MediaAltPreviewEntry[];
|
|
304
|
+
branchDelta: BranchRef[];
|
|
305
|
+
/** The placement counts by bucket, summed across all entries. */
|
|
306
|
+
counts: { willFill: number; customized: number; decorativeSkipped: number };
|
|
307
|
+
}
|
|
308
|
+
|
|
229
309
|
/** What a route's single `form` export presents to a view component: whichever content action
|
|
230
310
|
* last failed, merged with every field optional. `error` is always set on a failure; the richer
|
|
231
311
|
* keys identify which guard refused. The media refusals ride here too, so the Media Library's one
|
|
232
|
-
* `form` prop carries a `?/mediaDelete
|
|
312
|
+
* `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
|
|
313
|
+
* refusal without a second type. */
|
|
233
314
|
export type ContentFormFailure = Partial<
|
|
234
|
-
SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure
|
|
315
|
+
SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure
|
|
235
316
|
>;
|
|
236
317
|
|
|
237
318
|
/** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
|
|
@@ -463,6 +544,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
463
544
|
let flash: MediaLibraryData['flash'] = null;
|
|
464
545
|
if (event.url.searchParams.get('deleted') === '1') flash = 'deleted';
|
|
465
546
|
else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
|
|
547
|
+
else if (event.url.searchParams.get('replaced') === '1') flash = 'replaced';
|
|
548
|
+
else if (event.url.searchParams.get('altPropagated') === '1') flash = 'altPropagated';
|
|
466
549
|
const flashError = event.url.searchParams.get('error');
|
|
467
550
|
let token: string;
|
|
468
551
|
try {
|
|
@@ -1452,7 +1535,353 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1452
1535
|
throw redirect(303, '/admin/media?updated=1');
|
|
1453
1536
|
}
|
|
1454
1537
|
|
|
1455
|
-
|
|
1538
|
+
/** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
|
|
1539
|
+
* an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
|
|
1540
|
+
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
|
|
1541
|
+
function replacementToken(slug: string, hash: string): string {
|
|
1542
|
+
return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
|
|
1546
|
+
* of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
|
|
1547
|
+
* each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
|
|
1548
|
+
* It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
|
|
1549
|
+
* rather than a partial plan, so the confirm dialog never shows a count it cannot stand behind.
|
|
1550
|
+
*
|
|
1551
|
+
* Wire contract: a fetch POST with the JSON body `{ oldHash, newHash, slug }`, the CSRF token in
|
|
1552
|
+
* the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
|
|
1553
|
+
* returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
|
|
1554
|
+
* with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
|
|
1555
|
+
* `type`/`status` from the body, never the HTTP status. */
|
|
1556
|
+
async function mediaReplacePreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan> {
|
|
1557
|
+
// CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
|
|
1558
|
+
// upload action. A failed check refuses before the session read or any GitHub call.
|
|
1559
|
+
if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
|
|
1560
|
+
return fail(403, { error: 'csrf', hash: '', usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
|
|
1561
|
+
}
|
|
1562
|
+
requireSession(event);
|
|
1563
|
+
|
|
1564
|
+
// Parse the JSON body. A malformed body or a hash that fails the 16-hex grammar refuses with a 400
|
|
1565
|
+
// before any GitHub read. The slug is the OLD asset's: a replace keeps the name and changes only the
|
|
1566
|
+
// content hash, so the repointed token carries the existing slug (an invalid slug falls back to a
|
|
1567
|
+
// bare-hash token below). It is cosmetic for the preview display; the apply re-derives it server-side.
|
|
1568
|
+
let payload: { oldHash?: unknown; newHash?: unknown; slug?: unknown };
|
|
1569
|
+
try {
|
|
1570
|
+
payload = JSON.parse(await event.request.text());
|
|
1571
|
+
} catch {
|
|
1572
|
+
return fail(400, { error: 'Could not read the replace request.', hash: '', usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
|
|
1573
|
+
}
|
|
1574
|
+
const oldHash = String(payload.oldHash ?? '');
|
|
1575
|
+
const newHash = String(payload.newHash ?? '');
|
|
1576
|
+
const slug = String(payload.slug ?? '');
|
|
1577
|
+
if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
|
|
1578
|
+
return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1582
|
+
const contentManifest = await readManifest(token);
|
|
1583
|
+
const newToken = replacementToken(slug, newHash);
|
|
1584
|
+
|
|
1585
|
+
// Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
|
|
1586
|
+
// throws out of here rather than degrading to an absent reference; catch it and fail closed, the
|
|
1587
|
+
// same posture the delete gate takes.
|
|
1588
|
+
let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
|
|
1589
|
+
try {
|
|
1590
|
+
plan = await planMediaRewrite<RepointPlacement>({
|
|
1591
|
+
backend: runtime.backend,
|
|
1592
|
+
token,
|
|
1593
|
+
concepts: runtime.concepts,
|
|
1594
|
+
contentManifest,
|
|
1595
|
+
hash: oldHash,
|
|
1596
|
+
transform: (md) => repointMediaRef(md, oldHash, newToken),
|
|
1597
|
+
});
|
|
1598
|
+
} catch {
|
|
1599
|
+
return fail(503, {
|
|
1600
|
+
error: 'Could not verify where this asset is used. Try again.',
|
|
1601
|
+
hash: oldHash,
|
|
1602
|
+
usage: [],
|
|
1603
|
+
foundIn: 0,
|
|
1604
|
+
} satisfies MediaReplaceFailure);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Enrich each planned entry with its title and permalink from the content manifest (the planner
|
|
1608
|
+
// carries neither). A planned entry always has a manifest row (the usage index is built from the
|
|
1609
|
+
// manifest), so the lookup hits; an id-only fallback keeps the type total if a row is ever absent.
|
|
1610
|
+
const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
|
|
1611
|
+
const entries: MediaReplacePreviewEntry[] = plan.entries.map((e) => {
|
|
1612
|
+
const row = byKey.get(`${e.concept}/${e.id}`);
|
|
1613
|
+
return {
|
|
1614
|
+
concept: e.concept,
|
|
1615
|
+
id: e.id,
|
|
1616
|
+
title: row?.title ?? e.id,
|
|
1617
|
+
permalink: row?.permalink,
|
|
1618
|
+
placements: e.placements,
|
|
1619
|
+
};
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
|
|
1626
|
+
* new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
|
|
1627
|
+
* re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
|
|
1628
|
+
* preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
|
|
1629
|
+
* (unlike delete, which only gates an in-use asset): a replace silently repoints published content,
|
|
1630
|
+
* so it always demands the type-to-confirm. An empty stored slug is never satisfiable, exactly like
|
|
1631
|
+
* delete. The plan runs strict, so an unverifiable usage read fails the replace closed (commits
|
|
1632
|
+
* nothing) rather than rewriting some references and leaving others.
|
|
1633
|
+
*
|
|
1634
|
+
* No R2 operation: the new bytes were already stored put-first by the upload action, and the old
|
|
1635
|
+
* bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
|
|
1636
|
+
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
|
|
1637
|
+
async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1638
|
+
const editor = requireSession(event);
|
|
1639
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1640
|
+
|
|
1641
|
+
const form = await event.request.formData();
|
|
1642
|
+
const oldHash = String(form.get('oldHash') ?? '');
|
|
1643
|
+
const newHash = String(form.get('newHash') ?? '');
|
|
1644
|
+
if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) throw error(400, 'Invalid media hash');
|
|
1645
|
+
const confirmSlug = String(form.get('confirmSlug') ?? '');
|
|
1646
|
+
|
|
1647
|
+
// The new asset's optimistic record rides the post (the same untrusted-record contract as save).
|
|
1648
|
+
// Find the row for newHash; its absence is a malformed or missing replacement, a 400.
|
|
1649
|
+
const record = parseMediaEntries(form.get('media')).find((r) => r.hash === newHash);
|
|
1650
|
+
if (!record) {
|
|
1651
|
+
return fail(400, {
|
|
1652
|
+
error: 'The replacement upload is missing or invalid.',
|
|
1653
|
+
hash: oldHash,
|
|
1654
|
+
usage: [],
|
|
1655
|
+
foundIn: 0,
|
|
1656
|
+
} satisfies MediaReplaceFailure);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// The old asset must be committed on main to be replaceable here. A branch-only upload has no main
|
|
1660
|
+
// row; it is replaced by editing its draft, not here.
|
|
1661
|
+
const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1662
|
+
const row = manifest[oldHash];
|
|
1663
|
+
if (!row) {
|
|
1664
|
+
return fail(404, {
|
|
1665
|
+
error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
|
|
1666
|
+
hash: oldHash,
|
|
1667
|
+
usage: [],
|
|
1668
|
+
foundIn: 0,
|
|
1669
|
+
} satisfies MediaReplaceFailure);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Media-enabled guard only: replace does no R2 write (the new bytes are already stored, the old
|
|
1673
|
+
// bytes are kept), so there is no bucket binding to resolve. Media-off still refuses before any
|
|
1674
|
+
// git write.
|
|
1675
|
+
if (!runtime.resolvedAssets.enabled) {
|
|
1676
|
+
return fail(503, { error: 'Media is not enabled for this site.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Re-derive the plan from a FRESH content-manifest read (never trust a client plan). The planner
|
|
1680
|
+
// runs strict, so an unverifiable branch read throws; catch it and fail the replace closed (commit
|
|
1681
|
+
// nothing) rather than rewriting a partial set of references. The repointed token keeps the OLD
|
|
1682
|
+
// asset's slug (server-authoritative `row.slug`): a replace changes only the content hash, so the
|
|
1683
|
+
// name in every reference stays the same (the new bytes resolve by hash regardless of the slug).
|
|
1684
|
+
const newToken = replacementToken(row.slug, record.hash);
|
|
1685
|
+
let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
|
|
1686
|
+
try {
|
|
1687
|
+
plan = await planMediaRewrite<RepointPlacement>({
|
|
1688
|
+
backend: runtime.backend,
|
|
1689
|
+
token,
|
|
1690
|
+
concepts: runtime.concepts,
|
|
1691
|
+
contentManifest: await readManifest(token),
|
|
1692
|
+
hash: oldHash,
|
|
1693
|
+
transform: (md) => repointMediaRef(md, oldHash, newToken),
|
|
1694
|
+
});
|
|
1695
|
+
} catch {
|
|
1696
|
+
return fail(503, {
|
|
1697
|
+
error: 'Could not verify where this asset is used. Try again.',
|
|
1698
|
+
hash: oldHash,
|
|
1699
|
+
usage: [],
|
|
1700
|
+
foundIn: 0,
|
|
1701
|
+
} satisfies MediaReplaceFailure);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// The typed-slug gate, ALWAYS required for replace. A blank stored slug can never be satisfied by
|
|
1705
|
+
// the empty default, so it is treated as never-confirmed (the confirm cannot be bypassed).
|
|
1706
|
+
if (row.slug === '' || confirmSlug !== row.slug) {
|
|
1707
|
+
log.warn('media.replace_blocked', { editor: editor.email, hash: oldHash, foundIn: plan.affectedCount });
|
|
1708
|
+
return fail(409, {
|
|
1709
|
+
error: `Type ${row.slug} to confirm replacing it in ${plan.affectedCount} ${plan.affectedCount === 1 ? 'entry' : 'entries'}.`,
|
|
1710
|
+
hash: oldHash,
|
|
1711
|
+
usage: [],
|
|
1712
|
+
foundIn: plan.affectedCount,
|
|
1713
|
+
} satisfies MediaReplaceFailure);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Commit atomically: every rewritten entry plus the new media.json row (the OLD row stays, so the
|
|
1717
|
+
// old bytes keep a row). One commit, the same conflict handling as delete.
|
|
1718
|
+
const changes: FileChange[] = plan.entries.map((e) => ({ path: e.path, content: e.newMarkdown }));
|
|
1719
|
+
changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
|
|
1720
|
+
|
|
1721
|
+
const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
|
|
1722
|
+
try {
|
|
1723
|
+
await commitFiles(
|
|
1724
|
+
runtime.backend,
|
|
1725
|
+
changes,
|
|
1726
|
+
{ message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
|
|
1727
|
+
token,
|
|
1728
|
+
);
|
|
1729
|
+
log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
|
|
1730
|
+
} catch (err) {
|
|
1731
|
+
commitFailure(commitFields, err, '/admin/media',
|
|
1732
|
+
'The site changed since you opened it. Reload and try again.');
|
|
1733
|
+
}
|
|
1734
|
+
throw redirect(303, '/admin/media?replaced=1');
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
|
|
1738
|
+
* asset's default alt across every published main entry that references it, bucketing each placement
|
|
1739
|
+
* (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
|
|
1740
|
+
* enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
|
|
1741
|
+
* The plan runs strict (fail-closed): an unverifiable usage read returns a 503 rather than a partial
|
|
1742
|
+
* plan, so the dialog never shows a count it cannot stand behind.
|
|
1743
|
+
*
|
|
1744
|
+
* Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
|
|
1745
|
+
* header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
|
|
1746
|
+
* ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
|
|
1747
|
+
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
|
|
1748
|
+
async function mediaAltPreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaAltPreviewPlan> {
|
|
1749
|
+
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
1750
|
+
// replace-preview actions. A failed check refuses before the session read or any GitHub call.
|
|
1751
|
+
if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
|
|
1752
|
+
return fail(403, { error: 'csrf' } satisfies MediaAltPropagateFailure);
|
|
1753
|
+
}
|
|
1754
|
+
requireSession(event);
|
|
1755
|
+
|
|
1756
|
+
let payload: { hash?: unknown };
|
|
1757
|
+
try {
|
|
1758
|
+
payload = JSON.parse(await event.request.text());
|
|
1759
|
+
} catch {
|
|
1760
|
+
return fail(400, { error: 'Could not read the request.' } satisfies MediaAltPropagateFailure);
|
|
1761
|
+
}
|
|
1762
|
+
const hash = String(payload.hash ?? '');
|
|
1763
|
+
if (!MEDIA_HASH_RE.test(hash)) {
|
|
1764
|
+
return fail(400, { error: 'Invalid media hash.' } satisfies MediaAltPropagateFailure);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1768
|
+
// The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
|
|
1769
|
+
// asset with no committed row has no default alt to push, so refuse.
|
|
1770
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1771
|
+
const row = mediaManifest[hash];
|
|
1772
|
+
if (!row) {
|
|
1773
|
+
return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
|
|
1777
|
+
// it and fail closed, the same posture replace and delete take.
|
|
1778
|
+
const contentManifest = await readManifest(token);
|
|
1779
|
+
let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
|
|
1780
|
+
try {
|
|
1781
|
+
plan = await planMediaRewrite<AltPlacement>({
|
|
1782
|
+
backend: runtime.backend,
|
|
1783
|
+
token,
|
|
1784
|
+
concepts: runtime.concepts,
|
|
1785
|
+
contentManifest,
|
|
1786
|
+
hash,
|
|
1787
|
+
transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite: false }),
|
|
1788
|
+
});
|
|
1789
|
+
} catch {
|
|
1790
|
+
return fail(503, { error: 'Could not verify where this asset is used. Try again.' } satisfies MediaAltPropagateFailure);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Enrich each planned entry with its title and permalink from the content manifest (the planner
|
|
1794
|
+
// carries neither), and aggregate the bucket counts across every placement.
|
|
1795
|
+
const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
|
|
1796
|
+
const counts = { willFill: 0, customized: 0, decorativeSkipped: 0 };
|
|
1797
|
+
const entries: MediaAltPreviewEntry[] = plan.entries.map((e) => {
|
|
1798
|
+
for (const p of e.placements) {
|
|
1799
|
+
if (p.bucket === 'will-fill') counts.willFill += 1;
|
|
1800
|
+
else if (p.bucket === 'customized') counts.customized += 1;
|
|
1801
|
+
else counts.decorativeSkipped += 1;
|
|
1802
|
+
}
|
|
1803
|
+
const manifestRow = byKey.get(`${e.concept}/${e.id}`);
|
|
1804
|
+
return {
|
|
1805
|
+
concept: e.concept,
|
|
1806
|
+
id: e.id,
|
|
1807
|
+
title: manifestRow?.title ?? e.id,
|
|
1808
|
+
permalink: manifestRow?.permalink,
|
|
1809
|
+
placements: e.placements,
|
|
1810
|
+
};
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
return { entries, branchDelta: plan.branchDelta, counts };
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
|
|
1817
|
+
* published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
|
|
1818
|
+
* commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
|
|
1819
|
+
* differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
|
|
1820
|
+
* is NO media.json change (the default alt is READ from the row, never rewritten there), and a
|
|
1821
|
+
* decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
|
|
1822
|
+
* that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
|
|
1823
|
+
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
|
|
1824
|
+
async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1825
|
+
const editor = requireSession(event);
|
|
1826
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
1827
|
+
|
|
1828
|
+
const form = await event.request.formData();
|
|
1829
|
+
const hash = String(form.get('hash') ?? '');
|
|
1830
|
+
if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
|
|
1831
|
+
// The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
|
|
1832
|
+
const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
|
|
1833
|
+
|
|
1834
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
|
|
1835
|
+
const row = mediaManifest[hash];
|
|
1836
|
+
if (!row) {
|
|
1837
|
+
return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Media-enabled guard only: alt fill does no R2 write, so there is no bucket binding to resolve.
|
|
1841
|
+
if (!runtime.resolvedAssets.enabled) {
|
|
1842
|
+
return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaAltPropagateFailure);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Re-derive from a FRESH content-manifest read with the actual overwrite choice. Strict, so an
|
|
1846
|
+
// unverifiable branch read throws; catch it and fail closed (commit nothing).
|
|
1847
|
+
let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
|
|
1848
|
+
try {
|
|
1849
|
+
plan = await planMediaRewrite<AltPlacement>({
|
|
1850
|
+
backend: runtime.backend,
|
|
1851
|
+
token,
|
|
1852
|
+
concepts: runtime.concepts,
|
|
1853
|
+
contentManifest: await readManifest(token),
|
|
1854
|
+
hash,
|
|
1855
|
+
transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
|
|
1856
|
+
});
|
|
1857
|
+
} catch {
|
|
1858
|
+
return fail(503, { error: 'Could not verify where this asset is used. Try again.' } satisfies MediaAltPropagateFailure);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// Commit only the entries the transform actually changed. A reported-but-unchanged placement (a
|
|
1862
|
+
// kept custom alt, a decorative hero) has after === before, so an entry with only those is a no-op
|
|
1863
|
+
// and is excluded. Nothing changed at all is a successful no-op: skip the commit, still redirect.
|
|
1864
|
+
const changed = plan.entries.filter((e) => e.placements.some((p) => p.after !== p.before));
|
|
1865
|
+
if (changed.length === 0) throw redirect(303, '/admin/media?altPropagated=1');
|
|
1866
|
+
|
|
1867
|
+
const changes: FileChange[] = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
|
|
1868
|
+
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
1869
|
+
try {
|
|
1870
|
+
await commitFiles(
|
|
1871
|
+
runtime.backend,
|
|
1872
|
+
changes,
|
|
1873
|
+
{ message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
|
|
1874
|
+
token,
|
|
1875
|
+
);
|
|
1876
|
+
log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
commitFailure(commitFields, err, '/admin/media',
|
|
1879
|
+
'The site changed since you opened it. Reload and try again.');
|
|
1880
|
+
}
|
|
1881
|
+
throw redirect(303, '/admin/media?altPropagated=1');
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
|
|
1456
1885
|
}
|
|
1457
1886
|
|
|
1458
1887
|
/** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|