@glw907/cairn-cms 0.56.2 → 0.57.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 (173) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +929 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +347 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +569 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +17 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +62 -11
  47. package/dist/content/validate.js +27 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +68 -2
  102. package/dist/sveltekit/content-routes.js +461 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  116. package/src/lib/components/EditPage.svelte +347 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +569 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +16 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +78 -13
  138. package/src/lib/content/validate.ts +26 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +573 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -17,11 +17,24 @@ import { cachedInstallationToken } from '../github/signing.js';
17
17
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
18
18
  import { isConflict } from '../github/types.js';
19
19
  import { log } from '../log/index.js';
20
- import { issueCsrfToken } from './csrf.js';
20
+ import { issueCsrfToken, validateCsrfHeader } from './csrf.js';
21
21
  import { requireSession } from './guard.js';
22
+ import { sniffMediaType, isDeniedUpload, extForMediaType } from '../media/sniff.js';
23
+ import { hashBytes, shortHash, slugifyFilename, r2Key } from '../media/naming.js';
24
+ import { mediaToken } from '../media/reference.js';
25
+ import { r2Store } from '../media/store.js';
26
+ import { parseMediaEntries, parseMediaManifest, upsertMediaEntry, removeMediaEntry, serializeMediaManifest } from '../media/manifest.js';
27
+ import type { MediaEntry } from '../media/manifest.js';
28
+ import { mediaLibraryEntry } from '../media/library-entry.js';
29
+ import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
30
+ import { buildUsageIndex } from '../media/usage.js';
31
+ import type { UsageEntry } from '../media/usage.js';
22
32
  import type { CookieJar, EventBase } from './types.js';
23
33
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
24
34
  import type { Editor, Role } from '../auth/types.js';
35
+ // R2Bucket is named only inside uploadAction to cast the raw binding for r2Store. It is a type-only
36
+ // import that never appears in an exported signature, so it does not reach the public `.d.ts`.
37
+ import type { R2Bucket } from '@cloudflare/workers-types';
25
38
 
26
39
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
27
40
  export interface NavConcept {
@@ -99,6 +112,14 @@ export interface EditData {
99
112
  slug: string;
100
113
  /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
101
114
  linkTargets: LinkTarget[];
115
+ /** The minimal media-resolver input the edit page builds its preview `resolveMedia` from, keyed by
116
+ * the 16-hex content hash and parallel to `linkTargets`. Empty when media is off or the read fails. */
117
+ mediaTargets: Record<string, { slug: string; ext: string; contentType: string }>;
118
+ /** The picker's human layer for each stored asset, keyed by the 16-hex content hash and projected
119
+ * from the same committed media manifest read that populates `mediaTargets`. The `hash` field
120
+ * duplicates the key, so the picker can iterate `Object.values`. Empty when media is off or the
121
+ * read fails (the same degradation path as `mediaTargets`). */
122
+ mediaLibrary: MediaLibrary;
102
123
  /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
103
124
  inboundLinks: InboundLink[];
104
125
  /** True when the entry has a pending branch, so the body above came from that branch. */
@@ -115,6 +136,25 @@ export interface EditData {
115
136
  preview: ResolvedPreview | null;
116
137
  }
117
138
 
139
+ /** One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
140
+ * projection stays decoupled from the Library-only usage facts. */
141
+ export interface MediaUsageInfo {
142
+ /** Distinct content entries that reference the asset (count by distinct concept+id). */
143
+ count: number;
144
+ /** Every where-used row (published and edit-branch origins), for the detail's grouped list. */
145
+ entries: UsageEntry[];
146
+ }
147
+
148
+ /** The Media Library screen's data: the unioned assets, the per-hash usage overlay, and the
149
+ * degraded-load error. The usage overlay is keyed by content hash; an asset with no references
150
+ * simply has no key, which the screen renders as "no references found". */
151
+ export interface MediaLibraryData {
152
+ assets: MediaLibraryEntry[];
153
+ /** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
154
+ usage: Record<string, MediaUsageInfo>;
155
+ error: string | null;
156
+ }
157
+
118
158
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
119
159
  export interface ContentEvent extends EventBase<GithubKeyEnv> {
120
160
  params: Record<string, string>;
@@ -156,10 +196,45 @@ export interface RenameFailure {
156
196
  error: string;
157
197
  }
158
198
 
199
+ /** A refused media delete: `fail(404)` for an asset not committed on the default branch, or
200
+ * `fail(409)` when a fresh usage read finds the asset still in use and the typed-slug override
201
+ * was not given. `fail(503)` covers media-off or a missing bucket binding. */
202
+ export interface MediaDeleteRefusal {
203
+ /** The one-line human summary every action failure carries. */
204
+ error: string;
205
+ /** The refused asset's content hash, so the dialog marks the right asset. */
206
+ hash: string;
207
+ /** The where-used rows (published first, then by branch) the in-use face lists; empty otherwise. */
208
+ usage: UsageEntry[];
209
+ /** The distinct-entry count behind the refusal; zero when the asset is uncommitted. */
210
+ foundIn: number;
211
+ }
212
+
213
+ /** A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
214
+ * `fail(400)` for an invalid slug. */
215
+ export interface MediaUpdateFailure {
216
+ /** The one-line human summary every action failure carries. */
217
+ error: string;
218
+ }
219
+
159
220
  /** What a route's single `form` export presents to a view component: whichever content action
160
221
  * last failed, merged with every field optional. `error` is always set on a failure; the richer
161
- * keys identify which guard refused. */
162
- export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
222
+ * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
223
+ * `form` prop carries a `?/mediaDelete` or `?/mediaUpdate` refusal without a second type. */
224
+ export type ContentFormFailure = Partial<
225
+ SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure
226
+ >;
227
+
228
+ /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
229
+ * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
230
+ * `reused` is true when identical bytes were already stored, so the second upload did no second put;
231
+ * `mismatch` flags an existing object whose stored content type differs from this sniff. */
232
+ export interface UploadResult {
233
+ reference: string;
234
+ record: MediaEntry;
235
+ reused: boolean;
236
+ mismatch: boolean;
237
+ }
163
238
 
164
239
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
165
240
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
@@ -192,6 +267,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
192
267
  return raw === null ? emptyManifest() : parseManifest(raw);
193
268
  }
194
269
 
270
+ /** Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
271
+ * or corrupt file to null (an empty manifest). The committed file is always our own serialization,
272
+ * so the catch only guards a hand-edited or truncated file rather than a normal path. */
273
+ function parseMediaJson(raw: string | null): unknown {
274
+ if (raw === null) return null;
275
+ try {
276
+ return JSON.parse(raw);
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
195
282
  /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
196
283
  * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
197
284
  * the path confinement), or a concept this site does not configure. Every ref consumer
@@ -353,6 +440,73 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
353
440
  }
354
441
  }
355
442
 
443
+ /** The admin Media Library load: union the media manifest across main and every open cairn/*
444
+ * branch (so a not-yet-published asset shows), project each row through the shared
445
+ * mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
446
+ * hash. The assets union and the usage overlay degrade independently: a usage-build failure
447
+ * still lists the assets with an empty overlay, and a wholesale read failure degrades to the
448
+ * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
449
+ async function mediaLibraryLoad(event: ContentEvent): Promise<MediaLibraryData> {
450
+ requireSession(event);
451
+ let token: string;
452
+ try {
453
+ token = await mintToken(event.platform?.env ?? {});
454
+ } catch {
455
+ return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.' };
456
+ }
457
+
458
+ // Union the media manifest by hash: main's rows first, then any branch hash not already present.
459
+ // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
460
+ // absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
461
+ // The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
462
+ // not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
463
+ const union = new Map<string, MediaEntry>();
464
+ let branchNames: string[] = [];
465
+ try {
466
+ const mediaRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
467
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
468
+ union.set(hash, e);
469
+ }
470
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
471
+ branchNames = names;
472
+ const branchManifests = await Promise.all(
473
+ names.map((name) =>
474
+ readRaw({ ...runtime.backend, branch: name }, runtime.mediaManifestPath, token)
475
+ .then((raw) => parseMediaManifest(parseMediaJson(raw)))
476
+ .catch(() => ({}) as Record<string, MediaEntry>),
477
+ ),
478
+ );
479
+ for (const manifest of branchManifests) {
480
+ for (const [hash, e] of Object.entries(manifest)) {
481
+ if (!union.has(hash)) union.set(hash, e);
482
+ }
483
+ }
484
+ } catch {
485
+ // A wholesale read failure leaves whatever rows were already unioned; the screen lists them
486
+ // with no usage overlay rather than failing.
487
+ return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.' };
488
+ }
489
+ const assets = [...union.values()].map(mediaLibraryEntry);
490
+
491
+ // Build the where-used overlay from main's content manifest plus the open branches. A failure
492
+ // here keeps the asset list intact with an empty overlay, since the screen still lists assets.
493
+ let usage: Record<string, MediaUsageInfo> = {};
494
+ try {
495
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
496
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
497
+ // Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
498
+ // best-effort behavior (a failed branch read degrades that one branch, not the screen).
499
+ const index = await buildUsageIndex(runtime.backend, token, runtime.concepts, manifest, { branches: branchNames });
500
+ for (const [hash, entries] of index) {
501
+ usage[hash] = { count: distinctEntryCount(entries), entries };
502
+ }
503
+ } catch {
504
+ usage = {};
505
+ }
506
+
507
+ return { assets, usage, error: null };
508
+ }
509
+
356
510
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
357
511
  async function createAction(event: ContentEvent): Promise<never> {
358
512
  requireSession(event);
@@ -393,6 +547,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
393
547
  if (field.type === 'date') out[field.name] = dateInputValue(value);
394
548
  else if (field.type === 'boolean') out[field.name] = value === true;
395
549
  else if (field.type === 'tags' || field.type === 'freetags') out[field.name] = Array.isArray(value) ? value.map(String) : [];
550
+ // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
551
+ // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
552
+ else if (field.type === 'image') out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
396
553
  else out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
397
554
  }
398
555
  return out;
@@ -415,10 +572,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
415
572
  // only when the probe found a branch, with the stage-1 main read serving as the published
416
573
  // signal either way.
417
574
  const branch = pendingBranch(concept.id, id);
418
- const [headSha, mainRaw, manifestRaw] = await Promise.all([
575
+ // The media manifest joins the concurrent batch only when media is on, read from the default
576
+ // branch (pending branches carry no copy). A rejected media read degrades to null so the edit
577
+ // never throws on a missing or unreadable media.json; the projection below treats null as empty.
578
+ const [headSha, mainRaw, manifestRaw, mediaRaw] = await Promise.all([
419
579
  branchHeadSha(runtime.backend, branch, token),
420
580
  readRaw(runtime.backend, path, token),
421
581
  readRaw(runtime.backend, runtime.manifestPath, token),
582
+ runtime.resolvedAssets.enabled
583
+ ? readRaw(runtime.backend, runtime.mediaManifestPath, token).catch(() => null)
584
+ : Promise.resolve(null),
422
585
  ]);
423
586
  const pending = headSha !== null;
424
587
  const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
@@ -443,6 +606,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
443
606
  inbound = inboundLinks(manifest, concept.id, id);
444
607
  }
445
608
 
609
+ // Project the one committed media manifest read two ways: the minimal resolver triple the preview
610
+ // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
611
+ // A corrupt committed file degrades both to empty, not a throw.
612
+ const mediaTargets: EditData['mediaTargets'] = {};
613
+ const mediaLibrary: EditData['mediaLibrary'] = {};
614
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
615
+ mediaTargets[hash] = { slug: e.slug, ext: e.ext, contentType: e.contentType };
616
+ mediaLibrary[hash] = mediaLibraryEntry(e);
617
+ }
618
+
446
619
  return {
447
620
  conceptId: concept.id,
448
621
  id,
@@ -457,6 +630,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
457
630
  error: event.url.searchParams.get('error'),
458
631
  slug: slugFromId(id, datePrefix),
459
632
  linkTargets,
633
+ mediaTargets,
634
+ mediaLibrary,
460
635
  inboundLinks: inbound,
461
636
  pending,
462
637
  published,
@@ -512,6 +687,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
512
687
  /** The draft-target tokens the body links to, for save's warning query. */
513
688
  draftLinks: string[];
514
689
  token: string;
690
+ /** The merged media.json change this save committed to the branch, when media is on and the
691
+ * post carried records. Publish reuses it verbatim so the main commit promotes the exact same
692
+ * merged content (decision 1: the default-branch base is read once, here, not re-merged at
693
+ * publish). Absent when media is off or no records were posted. */
694
+ mediaChange?: FileChange;
515
695
  }
516
696
 
517
697
  /** The shared core of save and publish: parse the posted form, validate the frontmatter,
@@ -540,6 +720,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
540
720
  const markdown = serializeMarkdown(result.data, body);
541
721
  const token = await mintToken(event.platform?.env ?? {});
542
722
 
723
+ // Merge the editor's optimistic media records into the media manifest, gated on media being on
724
+ // and at least one valid record posted. The base is read from the default branch (never the
725
+ // pending branch), so each save's union starts from main's committed rows, and decision 1's
726
+ // last-writer-wins-by-hash race is the accepted trade. The merged file rides the branch commit
727
+ // below and, carried on SaveHold, the publish commit, so both reuse the same content with no
728
+ // second read. When media is off or no records arrive, nothing touches media.json.
729
+ let mediaChange: FileChange | undefined;
730
+ if (runtime.resolvedAssets.enabled) {
731
+ const records = parseMediaEntries(form.get('media'));
732
+ if (records.length > 0) {
733
+ const baseRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
734
+ let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
735
+ for (const record of records) {
736
+ mediaManifest = upsertMediaEntry(mediaManifest, record);
737
+ }
738
+ mediaChange = { path: runtime.mediaManifestPath, content: serializeMediaManifest(mediaManifest) };
739
+ }
740
+ }
741
+
543
742
  // Upsert this entry's row into main's manifest in memory, for the link guard here and for
544
743
  // the publish commit. The save commits no manifest change; publish lands the upsert on main.
545
744
  const manifest = await readManifest(token);
@@ -586,7 +785,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
586
785
  try {
587
786
  branchSha = await commitFiles(
588
787
  { ...runtime.backend, branch },
589
- [{ path, content: markdown }],
788
+ mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }],
590
789
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
591
790
  token,
592
791
  );
@@ -595,7 +794,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
595
794
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
596
795
  'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
597
796
  }
598
- return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
797
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
599
798
  }
600
799
 
601
800
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
@@ -628,16 +827,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
628
827
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
629
828
  const held = await saveToBranch(event, editor, concept, id);
630
829
  if (!('branchSha' in held)) return held;
631
- const { path, markdown, branch, branchSha, manifest, token } = held;
830
+ const { path, markdown, branch, branchSha, manifest, token, mediaChange } = held;
831
+
832
+ // The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
833
+ // no re-read or re-merge here). Promote it to main alongside the body and the content manifest
834
+ // in one atomic commit, or commit those two alone when the save touched no media.
835
+ const changes: FileChange[] = [
836
+ { path, content: markdown },
837
+ { path: runtime.manifestPath, content: serializeManifest(manifest) },
838
+ ];
839
+ if (mediaChange) changes.push(mediaChange);
632
840
 
633
841
  const commitFields = { concept: concept.id, id, editor: editor.email };
634
842
  try {
635
843
  await commitFiles(
636
844
  runtime.backend,
637
- [
638
- { path, content: markdown },
639
- { path: runtime.manifestPath, content: serializeManifest(manifest) },
640
- ],
845
+ changes,
641
846
  { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
642
847
  token,
643
848
  );
@@ -937,5 +1142,361 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
937
1142
  throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
938
1143
  }
939
1144
 
940
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
1145
+ /**
1146
+ * Ingest an uploaded image: the JSON/fetch endpoint with the untrusted-input contract (spec piece
1147
+ * 2, decisions 1 to 3). The body is the raw file bytes, read once; the human metadata travels in
1148
+ * percent-encoded `X-Cairn-*` request headers. The server owns every committed field and trusts no
1149
+ * client value: it sniffs the real type, screens the engine deny-list, re-hashes, re-derives the
1150
+ * ext and slug, caps and sanitizes the human fields, and clamps the advisory dimensions. It stores
1151
+ * put-first to R2 with content-addressed dedup (no second put for identical bytes, no
1152
+ * compensating delete) and commits nothing to git.
1153
+ *
1154
+ * Wire contract: this is a SvelteKit form action, so for a JSON request SvelteKit serializes the
1155
+ * result into a 200 JSON envelope `{ type, status, data }`. A `fail(status, ...)` rides the
1156
+ * envelope's `status` field, NOT the HTTP response status (the HTTP status stays 200); a client
1157
+ * parses `type`/`status` from the body, never `Response.status`. Success returns a plain
1158
+ * `UploadResult` (also a 200 envelope). The action logs `media.upload_failed` on a refusal and
1159
+ * `media.uploaded` on success.
1160
+ *
1161
+ * Session authority: behind `createAuthGuard` the guard is the production session gate. An
1162
+ * unauthenticated admin POST is redirected 303 by the guard before this action runs (an opaque,
1163
+ * status-0 response under the client's `redirect: 'manual'`), so the `fail(401, 'session-expired')`
1164
+ * below is a belt-and-suspenders for a direct or un-guarded call, not the primary path.
1165
+ */
1166
+ async function uploadAction(event: ContentEvent): Promise<ReturnType<typeof fail> | UploadResult> {
1167
+ // Read the editor up front for log attribution; the gate at step 4 enforces its presence. The
1168
+ // pre-session gates (1 to 3) may log with an undefined editor email, which is fine.
1169
+ const editor = event.locals.editor ?? null;
1170
+ const refuse = (status: number, reason: string): ReturnType<typeof fail> => {
1171
+ log.warn('media.upload_failed', { editor: editor?.email, reason });
1172
+ return fail(status, { error: reason });
1173
+ };
1174
+
1175
+ // 1. Media on.
1176
+ const resolved = runtime.resolvedAssets;
1177
+ if (!resolved.enabled) return refuse(503, 'media-disabled');
1178
+
1179
+ // 2. Content-Length before the body is read: an absent or non-positive-integer length is a 411,
1180
+ // an oversize length is a 413. Both refuse before the bytes are buffered. The header is
1181
+ // client-advisory, so the real DoS bound is the Worker request-size limit, not maxUploadBytes:
1182
+ // a lying client still buffers up to the platform ceiling before the post-read recheck (step 5).
1183
+ const lengthHeader = event.request.headers.get('content-length');
1184
+ const length = lengthHeader === null ? NaN : Number(lengthHeader);
1185
+ if (!Number.isInteger(length) || length <= 0) return refuse(411, 'length-required');
1186
+ if (length > resolved.maxUploadBytes) return refuse(413, 'too-large');
1187
+
1188
+ // 3. CSRF from the X-Cairn-CSRF header (no body clone): the action is the CSRF authority for the
1189
+ // raw-body upload, since the guard runs its form-CSRF only on form content types.
1190
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1191
+ return refuse(403, 'csrf');
1192
+ }
1193
+
1194
+ // 4. JSON-aware session (belt-and-suspenders; see the docstring): behind the guard an
1195
+ // unauthenticated POST is already 303'd before this runs. For a direct or un-guarded call,
1196
+ // read the resolved editor directly and refuse with a 401 envelope rather than a 303 redirect.
1197
+ if (!editor) return refuse(401, 'session-expired');
1198
+
1199
+ // 5. Read the body once. Content-Length is client-advisory, so a lying client could send more
1200
+ // than it declared; recheck the real size against the cap after the read.
1201
+ const bytes = new Uint8Array(await event.request.arrayBuffer());
1202
+ if (bytes.length > resolved.maxUploadBytes) return refuse(413, 'too-large');
1203
+
1204
+ // 6. Server re-derivation: trust nothing the client declared.
1205
+ const declaredType = event.request.headers.get('content-type') ?? undefined;
1206
+ const sniffed = sniffMediaType(bytes);
1207
+ if (isDeniedUpload(bytes, declaredType) || sniffed === null || !resolved.allowedTypes.includes(sniffed)) {
1208
+ return refuse(415, 'unsupported-type');
1209
+ }
1210
+ const ext = extForMediaType(sniffed);
1211
+ if (ext === null) return refuse(415, 'unsupported-type');
1212
+
1213
+ const full = await hashBytes(bytes);
1214
+ const hash = shortHash(full);
1215
+
1216
+ const decodedFilename = safeDecode(event.request.headers.get('x-cairn-filename'));
1217
+ const slug = slugifyFilename(decodedFilename);
1218
+ const originalFilename = sanitizeField(basename(decodedFilename), MAX_ORIGINAL_FILENAME);
1219
+ const alt = sanitizeField(safeDecode(event.request.headers.get('x-cairn-alt')), MAX_ALT);
1220
+ const displayNameRaw = sanitizeField(safeDecode(event.request.headers.get('x-cairn-display-name')), MAX_DISPLAY_NAME);
1221
+ const displayName = displayNameRaw || slug;
1222
+ const width = clampDimension(event.request.headers.get('x-cairn-width'));
1223
+ const height = clampDimension(event.request.headers.get('x-cairn-height'));
1224
+
1225
+ // 7. Store put-first with R2-head dedup, commit nothing. The raw bucket binding lives on
1226
+ // platform.env, which the engine reads through a structural cast (the engine does not declare
1227
+ // App.Platform). r2Store wraps it as the narrow MediaStore seam; R2Bucket is named only for
1228
+ // this cast and never in an exported signature.
1229
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1230
+ const rawBucket = platformEnv[resolved.bucketBinding];
1231
+ if (!rawBucket) return refuse(503, 'binding-missing');
1232
+ const store = r2Store(rawBucket as R2Bucket);
1233
+
1234
+ const key = r2Key(hash, ext);
1235
+ const existing = await store.head(key);
1236
+ let reused: boolean;
1237
+ let mismatch = false;
1238
+ if (existing !== null) {
1239
+ // The key derives from the 16-hex short hash (64 bits), so a distinct file could in principle
1240
+ // collide on it. The put stores the full sha256 as custom metadata; verify it here. A stored
1241
+ // sha256 that differs from this upload's full hash is a genuine short-hash collision: refuse,
1242
+ // never serve the first file's bytes under the second's reference. A stored object with no
1243
+ // sha256 (a legacy or manually-put object we cannot verify) proceeds as a dedup hit, best effort.
1244
+ const storedSha = existing.customMetadata?.sha256;
1245
+ if (storedSha !== undefined && storedSha !== full) return refuse(409, 'hash-collision');
1246
+ // Identical bytes are already stored: skip the put. A second upload does no second put, so a
1247
+ // concurrent dedup-reuse is never clobbered. Flag a stored type that disagrees with this sniff.
1248
+ reused = true;
1249
+ mismatch = existing.httpMetadata?.contentType !== undefined && existing.httpMetadata.contentType !== sniffed;
1250
+ } else {
1251
+ await store.put(
1252
+ key,
1253
+ bytes,
1254
+ { contentType: sniffed, cacheControl: 'public, max-age=31536000, immutable' },
1255
+ { sha256: full },
1256
+ );
1257
+ reused = false;
1258
+ }
1259
+
1260
+ const record: MediaEntry = {
1261
+ hash,
1262
+ sha256: full,
1263
+ slug,
1264
+ displayName,
1265
+ originalFilename,
1266
+ alt,
1267
+ ext,
1268
+ contentType: sniffed,
1269
+ bytes: bytes.length,
1270
+ width,
1271
+ height,
1272
+ createdAt: new Date().toISOString(),
1273
+ };
1274
+ const reference = mediaToken({ slug, hash });
1275
+
1276
+ log.info('media.uploaded', { editor: editor.email, hash, bytes: bytes.length, contentType: sniffed, reused });
1277
+ return { reference, record, reused, mismatch };
1278
+ }
1279
+
1280
+ /** A media slug is the same lowercase-alphanumeric-with-hyphens grammar the reference token uses. */
1281
+ const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1282
+ /** A 16-hex content-hash prefix, the immutable asset key. */
1283
+ const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
1284
+
1285
+ /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1286
+ * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
1287
+ * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
1288
+ * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
1289
+ * row removal FIRST, then delete the R2 object, so a failure after the commit leaves bytes with no
1290
+ * row (a benign orphan) rather than a row pointing at deleted bytes (a broken delivery). Scope:
1291
+ * 3c deletes assets committed on the default branch; a branch-only upload is removed by discarding
1292
+ * its draft, not here.
1293
+ *
1294
+ * The published-usage side of the gate trusts the content manifest's mediaRefs (kept fresh by
1295
+ * save/publish via manifestEntryFromFile), the same manifest-trust model the entry-delete gate
1296
+ * uses; a raw git edit that adds a media reference without a save/publish or a manifest regenerate
1297
+ * is not seen, matching the documented "regenerate after a raw edit" contract. The recheck reads
1298
+ * in STRICT mode, so a transient branch-read failure fails the delete closed rather than mistaking
1299
+ * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
1300
+ * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
1301
+ * hash, so a reference added in that window still resolves to bytes that may be gone, the same
1302
+ * delete-races-an-edit window every safe delete carries. */
1303
+ async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1304
+ const editor = requireSession(event);
1305
+ const token = await mintToken(event.platform?.env ?? {});
1306
+
1307
+ const form = await event.request.formData();
1308
+ const hash = String(form.get('hash') ?? '');
1309
+ if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
1310
+
1311
+ // The asset must be committed on the default branch to be deletable here. A branch-only upload
1312
+ // (the common 2b case before publish) has no main row; removing it is a discard of the draft.
1313
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1314
+ const row = manifest[hash];
1315
+ if (!row) {
1316
+ return fail(404, {
1317
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1318
+ hash,
1319
+ usage: [],
1320
+ foundIn: 0,
1321
+ } satisfies MediaDeleteRefusal);
1322
+ }
1323
+
1324
+ // The authoritative gate: a fresh usage read, never a client count. The index spans main's
1325
+ // content manifest and every open cairn/* branch. STRICT mode rethrows a branch-read failure
1326
+ // (rather than the display path's degrade-and-skip), so a transient branch read failing does not
1327
+ // make a still-referenced asset look orphaned and skip the typed-slug confirm.
1328
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1329
+ try {
1330
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1331
+ } catch {
1332
+ // Fail closed: we could not verify every place the asset is used, so refuse rather than risk
1333
+ // deleting bytes a branch still references.
1334
+ return fail(503, {
1335
+ error: 'Could not verify where this asset is used. Try again.',
1336
+ hash,
1337
+ usage: [],
1338
+ foundIn: 0,
1339
+ } satisfies MediaDeleteRefusal);
1340
+ }
1341
+ const rows = index.get(hash) ?? [];
1342
+ const foundIn = distinctEntryCount(rows);
1343
+
1344
+ if (rows.length > 0) {
1345
+ // In use: refuse unless the editor typed the slug to force it (the in-use face's confirmation).
1346
+ // An empty stored slug must never be satisfiable by the empty default, so a blank row.slug is
1347
+ // treated as never-confirmed: the typed confirm cannot be bypassed.
1348
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1349
+ if (row.slug === '' || confirmSlug !== row.slug) {
1350
+ log.warn('media.delete_blocked', { editor: editor.email, hash, foundIn });
1351
+ // Group published-first, then branch entries by branch name, so the list reads stably.
1352
+ const usage = [...rows].sort((a, b) => originRank(a) - originRank(b) || branchKey(a).localeCompare(branchKey(b)));
1353
+ return fail(409, {
1354
+ error: `Cannot delete ${row.slug}: found in ${foundIn} ${foundIn === 1 ? 'entry' : 'entries'}.`,
1355
+ hash,
1356
+ usage,
1357
+ foundIn,
1358
+ } satisfies MediaDeleteRefusal);
1359
+ }
1360
+ }
1361
+
1362
+ // Resolve the R2 bucket before the commit, so a missing binding refuses before any write.
1363
+ const resolved = runtime.resolvedAssets;
1364
+ if (!resolved.enabled) {
1365
+ return fail(503, { error: 'Media is not enabled for this site.', hash, usage: [], foundIn } satisfies MediaDeleteRefusal);
1366
+ }
1367
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1368
+ const rawBucket = platformEnv[resolved.bucketBinding];
1369
+ if (!rawBucket) {
1370
+ return fail(503, { error: 'The media bucket is not bound.', hash, usage: [], foundIn } satisfies MediaDeleteRefusal);
1371
+ }
1372
+ const store = r2Store(rawBucket as R2Bucket);
1373
+ // Derive the R2 key BEFORE the commit. A corrupt ext throws here, so a bad key refuses before
1374
+ // any write rather than after the row is already removed (which would orphan the bytes).
1375
+ const objectKey = r2Key(hash, row.ext);
1376
+
1377
+ // Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
1378
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1379
+ try {
1380
+ await commitFiles(
1381
+ runtime.backend,
1382
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }],
1383
+ { message: `Delete media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
1384
+ token,
1385
+ );
1386
+ log.info('commit.succeeded', commitFields);
1387
+ } catch (err) {
1388
+ commitFailure(commitFields, err, '/admin/media',
1389
+ 'The media manifest changed since you opened it. Reload and try again.');
1390
+ }
1391
+ // THEN delete the object. An absent object is a no-op (the R2 contract), so a dead row clears.
1392
+ await store.delete(objectKey);
1393
+ log.info('media.deleted', { editor: editor.email, hash });
1394
+ throw redirect(303, '/admin/media?deleted=1');
1395
+ }
1396
+
1397
+ /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1398
+ * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1399
+ * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
1400
+ * next placement, never a propagating edit of the alt already committed in existing placements. */
1401
+ async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1402
+ const editor = requireSession(event);
1403
+ const token = await mintToken(event.platform?.env ?? {});
1404
+
1405
+ const form = await event.request.formData();
1406
+ const hash = String(form.get('hash') ?? '');
1407
+ if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
1408
+
1409
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1410
+ const row = manifest[hash];
1411
+ if (!row) {
1412
+ return fail(404, { error: 'That asset is not committed.' } satisfies MediaUpdateFailure);
1413
+ }
1414
+
1415
+ const displayName = sanitizeField(String(form.get('displayName') ?? ''), MAX_DISPLAY_NAME);
1416
+ const slug = String(form.get('slug') ?? '').trim();
1417
+ const alt = sanitizeField(String(form.get('alt') ?? ''), MAX_ALT);
1418
+ if (!MEDIA_SLUG_RE.test(slug)) {
1419
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies MediaUpdateFailure);
1420
+ }
1421
+
1422
+ const edited: MediaEntry = { ...row, displayName: displayName || slug, slug, alt };
1423
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1424
+ try {
1425
+ await commitFiles(
1426
+ runtime.backend,
1427
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }],
1428
+ { message: `Update media: ${edited.slug}`, author: { name: editor.displayName, email: editor.email } },
1429
+ token,
1430
+ );
1431
+ log.info('commit.succeeded', commitFields);
1432
+ } catch (err) {
1433
+ commitFailure(commitFields, err, '/admin/media',
1434
+ 'The media manifest changed since you opened it. Reload and try again.');
1435
+ }
1436
+ throw redirect(303, '/admin/media?updated=1');
1437
+ }
1438
+
1439
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1440
+ }
1441
+
1442
+ /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1443
+ * so a generous cap rejects only abuse-scale input. */
1444
+ const MAX_ALT = 160;
1445
+ /** The cap, in characters, on the stored display name. */
1446
+ const MAX_DISPLAY_NAME = 120;
1447
+ /** The cap, in characters, on the stored original filename. */
1448
+ const MAX_ORIGINAL_FILENAME = 120;
1449
+ /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
1450
+ const MAX_DIMENSION = 60000;
1451
+
1452
+ /** Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
1453
+ * so a hostile `X-Cairn-*` value cannot throw past the gate. */
1454
+ function safeDecode(value: string | null): string {
1455
+ if (value === null) return '';
1456
+ try {
1457
+ return decodeURIComponent(value);
1458
+ } catch {
1459
+ return '';
1460
+ }
1461
+ }
1462
+
1463
+ /** The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
1464
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record. */
1465
+ function basename(name: string): string {
1466
+ const parts = name.split(/[/\\]/);
1467
+ return parts[parts.length - 1];
1468
+ }
1469
+
1470
+ /** Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
1471
+ * refusal lists "Published on the site" first, then the edit-branch references. */
1472
+ function originRank(entry: UsageEntry): number {
1473
+ return entry.origin.kind === 'published' ? 0 : 1;
1474
+ }
1475
+
1476
+ /** A where-used row's branch name for the secondary sort (the empty string for a published row,
1477
+ * which sorts ahead of any branch by `originRank` already). */
1478
+ function branchKey(entry: UsageEntry): string {
1479
+ return entry.origin.kind === 'branch' ? entry.origin.branch : '';
1480
+ }
1481
+
1482
+ /** The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
1483
+ * same entry are two rows but one distinct entry, so count by concept/id. */
1484
+ function distinctEntryCount(rows: UsageEntry[]): number {
1485
+ return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
1486
+ }
1487
+
1488
+ /** Strip control characters from a human field and cap it at `max` characters. Control characters
1489
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON. */
1490
+ function sanitizeField(value: string, max: number): string {
1491
+ // eslint-disable-next-line no-control-regex
1492
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
1493
+ }
1494
+
1495
+ /** Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
1496
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`). */
1497
+ function clampDimension(value: string | null): number | null {
1498
+ if (value === null) return null;
1499
+ const n = Number(value);
1500
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DIMENSION) return null;
1501
+ return n;
941
1502
  }