@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.
- package/CHANGELOG.md +96 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +347 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/EditPage.svelte +347 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|