@glw907/cairn-cms 0.56.2 → 0.57.1

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 +134 -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 +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -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 +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -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 +22 -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 +64 -11
  47. package/dist/content/validate.js +31 -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 +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -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 +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -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 +578 -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 +20 -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 +80 -13
  138. package/src/lib/content/validate.ts +29 -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 +589 -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,34 @@ 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
+ /** The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
156
+ * load, distinct from a prior action's conflict error (see `flashError`), so a read failure and a
157
+ * redirected commit conflict never overwrite each other. */
158
+ error: string | null;
159
+ /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
160
+ * `?updated=1`, null otherwise. The component renders a polite success strip for each. */
161
+ flash: 'deleted' | 'updated' | null;
162
+ /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
163
+ * its own slot rather than the degraded-load `error` above, so the two never collide. */
164
+ flashError: string | null;
165
+ }
166
+
118
167
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
119
168
  export interface ContentEvent extends EventBase<GithubKeyEnv> {
120
169
  params: Record<string, string>;
@@ -156,10 +205,45 @@ export interface RenameFailure {
156
205
  error: string;
157
206
  }
158
207
 
208
+ /** A refused media delete: `fail(404)` for an asset not committed on the default branch, or
209
+ * `fail(409)` when a fresh usage read finds the asset still in use and the typed-slug override
210
+ * was not given. `fail(503)` covers media-off or a missing bucket binding. */
211
+ export interface MediaDeleteRefusal {
212
+ /** The one-line human summary every action failure carries. */
213
+ error: string;
214
+ /** The refused asset's content hash, so the dialog marks the right asset. */
215
+ hash: string;
216
+ /** The where-used rows (published first, then by branch) the in-use face lists; empty otherwise. */
217
+ usage: UsageEntry[];
218
+ /** The distinct-entry count behind the refusal; zero when the asset is uncommitted. */
219
+ foundIn: number;
220
+ }
221
+
222
+ /** A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
223
+ * `fail(400)` for an invalid slug. */
224
+ export interface MediaUpdateFailure {
225
+ /** The one-line human summary every action failure carries. */
226
+ error: string;
227
+ }
228
+
159
229
  /** What a route's single `form` export presents to a view component: whichever content action
160
230
  * 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>;
231
+ * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
232
+ * `form` prop carries a `?/mediaDelete` or `?/mediaUpdate` refusal without a second type. */
233
+ export type ContentFormFailure = Partial<
234
+ SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure
235
+ >;
236
+
237
+ /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
238
+ * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
239
+ * `reused` is true when identical bytes were already stored, so the second upload did no second put;
240
+ * `mismatch` flags an existing object whose stored content type differs from this sniff. */
241
+ export interface UploadResult {
242
+ reference: string;
243
+ record: MediaEntry;
244
+ reused: boolean;
245
+ mismatch: boolean;
246
+ }
163
247
 
164
248
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
165
249
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
@@ -192,6 +276,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
192
276
  return raw === null ? emptyManifest() : parseManifest(raw);
193
277
  }
194
278
 
279
+ /** Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
280
+ * or corrupt file to null (an empty manifest). The committed file is always our own serialization,
281
+ * so the catch only guards a hand-edited or truncated file rather than a normal path. */
282
+ function parseMediaJson(raw: string | null): unknown {
283
+ if (raw === null) return null;
284
+ try {
285
+ return JSON.parse(raw);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
195
291
  /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
196
292
  * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
197
293
  * the path confinement), or a concept this site does not configure. Every ref consumer
@@ -353,6 +449,80 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
353
449
  }
354
450
  }
355
451
 
452
+ /** The admin Media Library load: union the media manifest across main and every open cairn/*
453
+ * branch (so a not-yet-published asset shows), project each row through the shared
454
+ * mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
455
+ * hash. The assets union and the usage overlay degrade independently: a usage-build failure
456
+ * still lists the assets with an empty overlay, and a wholesale read failure degrades to the
457
+ * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
458
+ async function mediaLibraryLoad(event: ContentEvent): Promise<MediaLibraryData> {
459
+ requireSession(event);
460
+ // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
461
+ // `?publishedAll` grammar: a deleted/updated success flag and a commit-conflict error. The
462
+ // conflict error rides its own slot so it never collides with the degraded-load `error` below.
463
+ let flash: MediaLibraryData['flash'] = null;
464
+ if (event.url.searchParams.get('deleted') === '1') flash = 'deleted';
465
+ else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
466
+ const flashError = event.url.searchParams.get('error');
467
+ let token: string;
468
+ try {
469
+ token = await mintToken(event.platform?.env ?? {});
470
+ } catch {
471
+ return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
472
+ }
473
+
474
+ // Union the media manifest by hash: main's rows first, then any branch hash not already present.
475
+ // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
476
+ // absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
477
+ // The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
478
+ // not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
479
+ const union = new Map<string, MediaEntry>();
480
+ let branchNames: string[] = [];
481
+ try {
482
+ const mediaRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
483
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
484
+ union.set(hash, e);
485
+ }
486
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
487
+ branchNames = names;
488
+ const branchManifests = await Promise.all(
489
+ names.map((name) =>
490
+ readRaw({ ...runtime.backend, branch: name }, runtime.mediaManifestPath, token)
491
+ .then((raw) => parseMediaManifest(parseMediaJson(raw)))
492
+ .catch(() => ({}) as Record<string, MediaEntry>),
493
+ ),
494
+ );
495
+ for (const manifest of branchManifests) {
496
+ for (const [hash, e] of Object.entries(manifest)) {
497
+ if (!union.has(hash)) union.set(hash, e);
498
+ }
499
+ }
500
+ } catch {
501
+ // A wholesale read failure leaves whatever rows were already unioned; the screen lists them
502
+ // with no usage overlay rather than failing.
503
+ return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.', flash, flashError };
504
+ }
505
+ const assets = [...union.values()].map(mediaLibraryEntry);
506
+
507
+ // Build the where-used overlay from main's content manifest plus the open branches. A failure
508
+ // here keeps the asset list intact with an empty overlay, since the screen still lists assets.
509
+ let usage: Record<string, MediaUsageInfo> = {};
510
+ try {
511
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
512
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
513
+ // Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
514
+ // best-effort behavior (a failed branch read degrades that one branch, not the screen).
515
+ const index = await buildUsageIndex(runtime.backend, token, runtime.concepts, manifest, { branches: branchNames });
516
+ for (const [hash, entries] of index) {
517
+ usage[hash] = { count: distinctEntryCount(entries), entries };
518
+ }
519
+ } catch {
520
+ usage = {};
521
+ }
522
+
523
+ return { assets, usage, error: null, flash, flashError };
524
+ }
525
+
356
526
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
357
527
  async function createAction(event: ContentEvent): Promise<never> {
358
528
  requireSession(event);
@@ -393,6 +563,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
393
563
  if (field.type === 'date') out[field.name] = dateInputValue(value);
394
564
  else if (field.type === 'boolean') out[field.name] = value === true;
395
565
  else if (field.type === 'tags' || field.type === 'freetags') out[field.name] = Array.isArray(value) ? value.map(String) : [];
566
+ // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
567
+ // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
568
+ else if (field.type === 'image') out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
396
569
  else out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
397
570
  }
398
571
  return out;
@@ -415,10 +588,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
415
588
  // only when the probe found a branch, with the stage-1 main read serving as the published
416
589
  // signal either way.
417
590
  const branch = pendingBranch(concept.id, id);
418
- const [headSha, mainRaw, manifestRaw] = await Promise.all([
591
+ // The media manifest joins the concurrent batch only when media is on, read from the default
592
+ // branch (pending branches carry no copy). A rejected media read degrades to null so the edit
593
+ // never throws on a missing or unreadable media.json; the projection below treats null as empty.
594
+ const [headSha, mainRaw, manifestRaw, mediaRaw] = await Promise.all([
419
595
  branchHeadSha(runtime.backend, branch, token),
420
596
  readRaw(runtime.backend, path, token),
421
597
  readRaw(runtime.backend, runtime.manifestPath, token),
598
+ runtime.resolvedAssets.enabled
599
+ ? readRaw(runtime.backend, runtime.mediaManifestPath, token).catch(() => null)
600
+ : Promise.resolve(null),
422
601
  ]);
423
602
  const pending = headSha !== null;
424
603
  const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
@@ -443,6 +622,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
443
622
  inbound = inboundLinks(manifest, concept.id, id);
444
623
  }
445
624
 
625
+ // Project the one committed media manifest read two ways: the minimal resolver triple the preview
626
+ // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
627
+ // A corrupt committed file degrades both to empty, not a throw.
628
+ const mediaTargets: EditData['mediaTargets'] = {};
629
+ const mediaLibrary: EditData['mediaLibrary'] = {};
630
+ for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
631
+ mediaTargets[hash] = { slug: e.slug, ext: e.ext, contentType: e.contentType };
632
+ mediaLibrary[hash] = mediaLibraryEntry(e);
633
+ }
634
+
446
635
  return {
447
636
  conceptId: concept.id,
448
637
  id,
@@ -457,6 +646,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
457
646
  error: event.url.searchParams.get('error'),
458
647
  slug: slugFromId(id, datePrefix),
459
648
  linkTargets,
649
+ mediaTargets,
650
+ mediaLibrary,
460
651
  inboundLinks: inbound,
461
652
  pending,
462
653
  published,
@@ -512,6 +703,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
512
703
  /** The draft-target tokens the body links to, for save's warning query. */
513
704
  draftLinks: string[];
514
705
  token: string;
706
+ /** The merged media.json change this save committed to the branch, when media is on and the
707
+ * post carried records. Publish reuses it verbatim so the main commit promotes the exact same
708
+ * merged content (decision 1: the default-branch base is read once, here, not re-merged at
709
+ * publish). Absent when media is off or no records were posted. */
710
+ mediaChange?: FileChange;
515
711
  }
516
712
 
517
713
  /** The shared core of save and publish: parse the posted form, validate the frontmatter,
@@ -540,6 +736,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
540
736
  const markdown = serializeMarkdown(result.data, body);
541
737
  const token = await mintToken(event.platform?.env ?? {});
542
738
 
739
+ // Merge the editor's optimistic media records into the media manifest, gated on media being on
740
+ // and at least one valid record posted. The base is read from the default branch (never the
741
+ // pending branch), so each save's union starts from main's committed rows, and decision 1's
742
+ // last-writer-wins-by-hash race is the accepted trade. The merged file rides the branch commit
743
+ // below and, carried on SaveHold, the publish commit, so both reuse the same content with no
744
+ // second read. When media is off or no records arrive, nothing touches media.json.
745
+ let mediaChange: FileChange | undefined;
746
+ if (runtime.resolvedAssets.enabled) {
747
+ const records = parseMediaEntries(form.get('media'));
748
+ if (records.length > 0) {
749
+ const baseRaw = await readRaw(runtime.backend, runtime.mediaManifestPath, token);
750
+ let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
751
+ for (const record of records) {
752
+ mediaManifest = upsertMediaEntry(mediaManifest, record);
753
+ }
754
+ mediaChange = { path: runtime.mediaManifestPath, content: serializeMediaManifest(mediaManifest) };
755
+ }
756
+ }
757
+
543
758
  // Upsert this entry's row into main's manifest in memory, for the link guard here and for
544
759
  // the publish commit. The save commits no manifest change; publish lands the upsert on main.
545
760
  const manifest = await readManifest(token);
@@ -586,7 +801,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
586
801
  try {
587
802
  branchSha = await commitFiles(
588
803
  { ...runtime.backend, branch },
589
- [{ path, content: markdown }],
804
+ mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }],
590
805
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
591
806
  token,
592
807
  );
@@ -595,7 +810,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
595
810
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
596
811
  'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
597
812
  }
598
- return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
813
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
599
814
  }
600
815
 
601
816
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
@@ -628,16 +843,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
628
843
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
629
844
  const held = await saveToBranch(event, editor, concept, id);
630
845
  if (!('branchSha' in held)) return held;
631
- const { path, markdown, branch, branchSha, manifest, token } = held;
846
+ const { path, markdown, branch, branchSha, manifest, token, mediaChange } = held;
847
+
848
+ // The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
849
+ // no re-read or re-merge here). Promote it to main alongside the body and the content manifest
850
+ // in one atomic commit, or commit those two alone when the save touched no media.
851
+ const changes: FileChange[] = [
852
+ { path, content: markdown },
853
+ { path: runtime.manifestPath, content: serializeManifest(manifest) },
854
+ ];
855
+ if (mediaChange) changes.push(mediaChange);
632
856
 
633
857
  const commitFields = { concept: concept.id, id, editor: editor.email };
634
858
  try {
635
859
  await commitFiles(
636
860
  runtime.backend,
637
- [
638
- { path, content: markdown },
639
- { path: runtime.manifestPath, content: serializeManifest(manifest) },
640
- ],
861
+ changes,
641
862
  { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
642
863
  token,
643
864
  );
@@ -937,5 +1158,361 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
937
1158
  throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
938
1159
  }
939
1160
 
940
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
1161
+ /**
1162
+ * Ingest an uploaded image: the JSON/fetch endpoint with the untrusted-input contract (spec piece
1163
+ * 2, decisions 1 to 3). The body is the raw file bytes, read once; the human metadata travels in
1164
+ * percent-encoded `X-Cairn-*` request headers. The server owns every committed field and trusts no
1165
+ * client value: it sniffs the real type, screens the engine deny-list, re-hashes, re-derives the
1166
+ * ext and slug, caps and sanitizes the human fields, and clamps the advisory dimensions. It stores
1167
+ * put-first to R2 with content-addressed dedup (no second put for identical bytes, no
1168
+ * compensating delete) and commits nothing to git.
1169
+ *
1170
+ * Wire contract: this is a SvelteKit form action, so for a JSON request SvelteKit serializes the
1171
+ * result into a 200 JSON envelope `{ type, status, data }`. A `fail(status, ...)` rides the
1172
+ * envelope's `status` field, NOT the HTTP response status (the HTTP status stays 200); a client
1173
+ * parses `type`/`status` from the body, never `Response.status`. Success returns a plain
1174
+ * `UploadResult` (also a 200 envelope). The action logs `media.upload_failed` on a refusal and
1175
+ * `media.uploaded` on success.
1176
+ *
1177
+ * Session authority: behind `createAuthGuard` the guard is the production session gate. An
1178
+ * unauthenticated admin POST is redirected 303 by the guard before this action runs (an opaque,
1179
+ * status-0 response under the client's `redirect: 'manual'`), so the `fail(401, 'session-expired')`
1180
+ * below is a belt-and-suspenders for a direct or un-guarded call, not the primary path.
1181
+ */
1182
+ async function uploadAction(event: ContentEvent): Promise<ReturnType<typeof fail> | UploadResult> {
1183
+ // Read the editor up front for log attribution; the gate at step 4 enforces its presence. The
1184
+ // pre-session gates (1 to 3) may log with an undefined editor email, which is fine.
1185
+ const editor = event.locals.editor ?? null;
1186
+ const refuse = (status: number, reason: string): ReturnType<typeof fail> => {
1187
+ log.warn('media.upload_failed', { editor: editor?.email, reason });
1188
+ return fail(status, { error: reason });
1189
+ };
1190
+
1191
+ // 1. Media on.
1192
+ const resolved = runtime.resolvedAssets;
1193
+ if (!resolved.enabled) return refuse(503, 'media-disabled');
1194
+
1195
+ // 2. Content-Length before the body is read: an absent or non-positive-integer length is a 411,
1196
+ // an oversize length is a 413. Both refuse before the bytes are buffered. The header is
1197
+ // client-advisory, so the real DoS bound is the Worker request-size limit, not maxUploadBytes:
1198
+ // a lying client still buffers up to the platform ceiling before the post-read recheck (step 5).
1199
+ const lengthHeader = event.request.headers.get('content-length');
1200
+ const length = lengthHeader === null ? NaN : Number(lengthHeader);
1201
+ if (!Number.isInteger(length) || length <= 0) return refuse(411, 'length-required');
1202
+ if (length > resolved.maxUploadBytes) return refuse(413, 'too-large');
1203
+
1204
+ // 3. CSRF from the X-Cairn-CSRF header (no body clone): the action is the CSRF authority for the
1205
+ // raw-body upload, since the guard runs its form-CSRF only on form content types.
1206
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1207
+ return refuse(403, 'csrf');
1208
+ }
1209
+
1210
+ // 4. JSON-aware session (belt-and-suspenders; see the docstring): behind the guard an
1211
+ // unauthenticated POST is already 303'd before this runs. For a direct or un-guarded call,
1212
+ // read the resolved editor directly and refuse with a 401 envelope rather than a 303 redirect.
1213
+ if (!editor) return refuse(401, 'session-expired');
1214
+
1215
+ // 5. Read the body once. Content-Length is client-advisory, so a lying client could send more
1216
+ // than it declared; recheck the real size against the cap after the read.
1217
+ const bytes = new Uint8Array(await event.request.arrayBuffer());
1218
+ if (bytes.length > resolved.maxUploadBytes) return refuse(413, 'too-large');
1219
+
1220
+ // 6. Server re-derivation: trust nothing the client declared.
1221
+ const declaredType = event.request.headers.get('content-type') ?? undefined;
1222
+ const sniffed = sniffMediaType(bytes);
1223
+ if (isDeniedUpload(bytes, declaredType) || sniffed === null || !resolved.allowedTypes.includes(sniffed)) {
1224
+ return refuse(415, 'unsupported-type');
1225
+ }
1226
+ const ext = extForMediaType(sniffed);
1227
+ if (ext === null) return refuse(415, 'unsupported-type');
1228
+
1229
+ const full = await hashBytes(bytes);
1230
+ const hash = shortHash(full);
1231
+
1232
+ const decodedFilename = safeDecode(event.request.headers.get('x-cairn-filename'));
1233
+ const slug = slugifyFilename(decodedFilename);
1234
+ const originalFilename = sanitizeField(basename(decodedFilename), MAX_ORIGINAL_FILENAME);
1235
+ const alt = sanitizeField(safeDecode(event.request.headers.get('x-cairn-alt')), MAX_ALT);
1236
+ const displayNameRaw = sanitizeField(safeDecode(event.request.headers.get('x-cairn-display-name')), MAX_DISPLAY_NAME);
1237
+ const displayName = displayNameRaw || slug;
1238
+ const width = clampDimension(event.request.headers.get('x-cairn-width'));
1239
+ const height = clampDimension(event.request.headers.get('x-cairn-height'));
1240
+
1241
+ // 7. Store put-first with R2-head dedup, commit nothing. The raw bucket binding lives on
1242
+ // platform.env, which the engine reads through a structural cast (the engine does not declare
1243
+ // App.Platform). r2Store wraps it as the narrow MediaStore seam; R2Bucket is named only for
1244
+ // this cast and never in an exported signature.
1245
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1246
+ const rawBucket = platformEnv[resolved.bucketBinding];
1247
+ if (!rawBucket) return refuse(503, 'binding-missing');
1248
+ const store = r2Store(rawBucket as R2Bucket);
1249
+
1250
+ const key = r2Key(hash, ext);
1251
+ const existing = await store.head(key);
1252
+ let reused: boolean;
1253
+ let mismatch = false;
1254
+ if (existing !== null) {
1255
+ // The key derives from the 16-hex short hash (64 bits), so a distinct file could in principle
1256
+ // collide on it. The put stores the full sha256 as custom metadata; verify it here. A stored
1257
+ // sha256 that differs from this upload's full hash is a genuine short-hash collision: refuse,
1258
+ // never serve the first file's bytes under the second's reference. A stored object with no
1259
+ // sha256 (a legacy or manually-put object we cannot verify) proceeds as a dedup hit, best effort.
1260
+ const storedSha = existing.customMetadata?.sha256;
1261
+ if (storedSha !== undefined && storedSha !== full) return refuse(409, 'hash-collision');
1262
+ // Identical bytes are already stored: skip the put. A second upload does no second put, so a
1263
+ // concurrent dedup-reuse is never clobbered. Flag a stored type that disagrees with this sniff.
1264
+ reused = true;
1265
+ mismatch = existing.httpMetadata?.contentType !== undefined && existing.httpMetadata.contentType !== sniffed;
1266
+ } else {
1267
+ await store.put(
1268
+ key,
1269
+ bytes,
1270
+ { contentType: sniffed, cacheControl: 'public, max-age=31536000, immutable' },
1271
+ { sha256: full },
1272
+ );
1273
+ reused = false;
1274
+ }
1275
+
1276
+ const record: MediaEntry = {
1277
+ hash,
1278
+ sha256: full,
1279
+ slug,
1280
+ displayName,
1281
+ originalFilename,
1282
+ alt,
1283
+ ext,
1284
+ contentType: sniffed,
1285
+ bytes: bytes.length,
1286
+ width,
1287
+ height,
1288
+ createdAt: new Date().toISOString(),
1289
+ };
1290
+ const reference = mediaToken({ slug, hash });
1291
+
1292
+ log.info('media.uploaded', { editor: editor.email, hash, bytes: bytes.length, contentType: sniffed, reused });
1293
+ return { reference, record, reused, mismatch };
1294
+ }
1295
+
1296
+ /** A media slug is the same lowercase-alphanumeric-with-hyphens grammar the reference token uses. */
1297
+ const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1298
+ /** A 16-hex content-hash prefix, the immutable asset key. */
1299
+ const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
1300
+
1301
+ /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1302
+ * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
1303
+ * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
1304
+ * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
1305
+ * row removal FIRST, then delete the R2 object, so a failure after the commit leaves bytes with no
1306
+ * row (a benign orphan) rather than a row pointing at deleted bytes (a broken delivery). Scope:
1307
+ * 3c deletes assets committed on the default branch; a branch-only upload is removed by discarding
1308
+ * its draft, not here.
1309
+ *
1310
+ * The published-usage side of the gate trusts the content manifest's mediaRefs (kept fresh by
1311
+ * save/publish via manifestEntryFromFile), the same manifest-trust model the entry-delete gate
1312
+ * uses; a raw git edit that adds a media reference without a save/publish or a manifest regenerate
1313
+ * is not seen, matching the documented "regenerate after a raw edit" contract. The recheck reads
1314
+ * in STRICT mode, so a transient branch-read failure fails the delete closed rather than mistaking
1315
+ * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
1316
+ * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
1317
+ * hash, so a reference added in that window still resolves to bytes that may be gone, the same
1318
+ * delete-races-an-edit window every safe delete carries. */
1319
+ async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1320
+ const editor = requireSession(event);
1321
+ const token = await mintToken(event.platform?.env ?? {});
1322
+
1323
+ const form = await event.request.formData();
1324
+ const hash = String(form.get('hash') ?? '');
1325
+ if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
1326
+
1327
+ // The asset must be committed on the default branch to be deletable here. A branch-only upload
1328
+ // (the common 2b case before publish) has no main row; removing it is a discard of the draft.
1329
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1330
+ const row = manifest[hash];
1331
+ if (!row) {
1332
+ return fail(404, {
1333
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1334
+ hash,
1335
+ usage: [],
1336
+ foundIn: 0,
1337
+ } satisfies MediaDeleteRefusal);
1338
+ }
1339
+
1340
+ // The authoritative gate: a fresh usage read, never a client count. The index spans main's
1341
+ // content manifest and every open cairn/* branch. STRICT mode rethrows a branch-read failure
1342
+ // (rather than the display path's degrade-and-skip), so a transient branch read failing does not
1343
+ // make a still-referenced asset look orphaned and skip the typed-slug confirm.
1344
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1345
+ try {
1346
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1347
+ } catch {
1348
+ // Fail closed: we could not verify every place the asset is used, so refuse rather than risk
1349
+ // deleting bytes a branch still references.
1350
+ return fail(503, {
1351
+ error: 'Could not verify where this asset is used. Try again.',
1352
+ hash,
1353
+ usage: [],
1354
+ foundIn: 0,
1355
+ } satisfies MediaDeleteRefusal);
1356
+ }
1357
+ const rows = index.get(hash) ?? [];
1358
+ const foundIn = distinctEntryCount(rows);
1359
+
1360
+ if (rows.length > 0) {
1361
+ // In use: refuse unless the editor typed the slug to force it (the in-use face's confirmation).
1362
+ // An empty stored slug must never be satisfiable by the empty default, so a blank row.slug is
1363
+ // treated as never-confirmed: the typed confirm cannot be bypassed.
1364
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1365
+ if (row.slug === '' || confirmSlug !== row.slug) {
1366
+ log.warn('media.delete_blocked', { editor: editor.email, hash, foundIn });
1367
+ // Group published-first, then branch entries by branch name, so the list reads stably.
1368
+ const usage = [...rows].sort((a, b) => originRank(a) - originRank(b) || branchKey(a).localeCompare(branchKey(b)));
1369
+ return fail(409, {
1370
+ error: `Cannot delete ${row.slug}: found in ${foundIn} ${foundIn === 1 ? 'entry' : 'entries'}.`,
1371
+ hash,
1372
+ usage,
1373
+ foundIn,
1374
+ } satisfies MediaDeleteRefusal);
1375
+ }
1376
+ }
1377
+
1378
+ // Resolve the R2 bucket before the commit, so a missing binding refuses before any write.
1379
+ const resolved = runtime.resolvedAssets;
1380
+ if (!resolved.enabled) {
1381
+ return fail(503, { error: 'Media is not enabled for this site.', hash, usage: [], foundIn } satisfies MediaDeleteRefusal);
1382
+ }
1383
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1384
+ const rawBucket = platformEnv[resolved.bucketBinding];
1385
+ if (!rawBucket) {
1386
+ return fail(503, { error: 'The media bucket is not bound.', hash, usage: [], foundIn } satisfies MediaDeleteRefusal);
1387
+ }
1388
+ const store = r2Store(rawBucket as R2Bucket);
1389
+ // Derive the R2 key BEFORE the commit. A corrupt ext throws here, so a bad key refuses before
1390
+ // any write rather than after the row is already removed (which would orphan the bytes).
1391
+ const objectKey = r2Key(hash, row.ext);
1392
+
1393
+ // Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
1394
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1395
+ try {
1396
+ await commitFiles(
1397
+ runtime.backend,
1398
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }],
1399
+ { message: `Delete media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } },
1400
+ token,
1401
+ );
1402
+ log.info('commit.succeeded', commitFields);
1403
+ } catch (err) {
1404
+ commitFailure(commitFields, err, '/admin/media',
1405
+ 'The media manifest changed since you opened it. Reload and try again.');
1406
+ }
1407
+ // THEN delete the object. An absent object is a no-op (the R2 contract), so a dead row clears.
1408
+ await store.delete(objectKey);
1409
+ log.info('media.deleted', { editor: editor.email, hash });
1410
+ throw redirect(303, '/admin/media?deleted=1');
1411
+ }
1412
+
1413
+ /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1414
+ * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1415
+ * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
1416
+ * next placement, never a propagating edit of the alt already committed in existing placements. */
1417
+ async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1418
+ const editor = requireSession(event);
1419
+ const token = await mintToken(event.platform?.env ?? {});
1420
+
1421
+ const form = await event.request.formData();
1422
+ const hash = String(form.get('hash') ?? '');
1423
+ if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
1424
+
1425
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1426
+ const row = manifest[hash];
1427
+ if (!row) {
1428
+ return fail(404, { error: 'That asset is not committed.' } satisfies MediaUpdateFailure);
1429
+ }
1430
+
1431
+ const displayName = sanitizeField(String(form.get('displayName') ?? ''), MAX_DISPLAY_NAME);
1432
+ const slug = String(form.get('slug') ?? '').trim();
1433
+ const alt = sanitizeField(String(form.get('alt') ?? ''), MAX_ALT);
1434
+ if (!MEDIA_SLUG_RE.test(slug)) {
1435
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies MediaUpdateFailure);
1436
+ }
1437
+
1438
+ const edited: MediaEntry = { ...row, displayName: displayName || slug, slug, alt };
1439
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1440
+ try {
1441
+ await commitFiles(
1442
+ runtime.backend,
1443
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }],
1444
+ { message: `Update media: ${edited.slug}`, author: { name: editor.displayName, email: editor.email } },
1445
+ token,
1446
+ );
1447
+ log.info('commit.succeeded', commitFields);
1448
+ } catch (err) {
1449
+ commitFailure(commitFields, err, '/admin/media',
1450
+ 'The media manifest changed since you opened it. Reload and try again.');
1451
+ }
1452
+ throw redirect(303, '/admin/media?updated=1');
1453
+ }
1454
+
1455
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1456
+ }
1457
+
1458
+ /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1459
+ * so a generous cap rejects only abuse-scale input. */
1460
+ const MAX_ALT = 160;
1461
+ /** The cap, in characters, on the stored display name. */
1462
+ const MAX_DISPLAY_NAME = 120;
1463
+ /** The cap, in characters, on the stored original filename. */
1464
+ const MAX_ORIGINAL_FILENAME = 120;
1465
+ /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
1466
+ const MAX_DIMENSION = 60000;
1467
+
1468
+ /** Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
1469
+ * so a hostile `X-Cairn-*` value cannot throw past the gate. */
1470
+ function safeDecode(value: string | null): string {
1471
+ if (value === null) return '';
1472
+ try {
1473
+ return decodeURIComponent(value);
1474
+ } catch {
1475
+ return '';
1476
+ }
1477
+ }
1478
+
1479
+ /** The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
1480
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record. */
1481
+ function basename(name: string): string {
1482
+ const parts = name.split(/[/\\]/);
1483
+ return parts[parts.length - 1];
1484
+ }
1485
+
1486
+ /** Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
1487
+ * refusal lists "Published on the site" first, then the edit-branch references. */
1488
+ function originRank(entry: UsageEntry): number {
1489
+ return entry.origin.kind === 'published' ? 0 : 1;
1490
+ }
1491
+
1492
+ /** A where-used row's branch name for the secondary sort (the empty string for a published row,
1493
+ * which sorts ahead of any branch by `originRank` already). */
1494
+ function branchKey(entry: UsageEntry): string {
1495
+ return entry.origin.kind === 'branch' ? entry.origin.branch : '';
1496
+ }
1497
+
1498
+ /** The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
1499
+ * same entry are two rows but one distinct entry, so count by concept/id. */
1500
+ function distinctEntryCount(rows: UsageEntry[]): number {
1501
+ return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
1502
+ }
1503
+
1504
+ /** Strip control characters from a human field and cap it at `max` characters. Control characters
1505
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON. */
1506
+ function sanitizeField(value: string, max: number): string {
1507
+ // eslint-disable-next-line no-control-regex
1508
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
1509
+ }
1510
+
1511
+ /** Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
1512
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`). */
1513
+ function clampDimension(value: string | null): number | null {
1514
+ if (value === null) return null;
1515
+ const n = Number(value);
1516
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DIMENSION) return null;
1517
+ return n;
941
1518
  }