@glw907/cairn-cms 0.56.1 → 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 +148 -0
- package/README.md +10 -4
- 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/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -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 +1045 -28
- 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 +19 -0
- package/dist/components/markdown-directives.js +52 -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/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -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 +8 -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/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -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 +57 -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/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** The default delivery base path when the AssetConfig omits one. */
|
|
2
|
+
const DEFAULT_PUBLIC_BASE = '/media';
|
|
3
|
+
/** The default maximum upload size, 25 MB. */
|
|
4
|
+
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
5
|
+
/** The default accepted upload MIME types: the common web image formats. */
|
|
6
|
+
const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'];
|
|
7
|
+
/** The built-in named transform presets. A site's `variants` merge over these, so a caller preset of
|
|
8
|
+
* the same name overrides the built-in. */
|
|
9
|
+
const BUILT_IN_PRESETS = {
|
|
10
|
+
thumb: { width: 320, height: 320, fit: 'cover' },
|
|
11
|
+
inline: { width: 800 },
|
|
12
|
+
card: { width: 640, height: 400, fit: 'cover' },
|
|
13
|
+
hero: { width: 1600, height: 900, fit: 'cover' },
|
|
14
|
+
};
|
|
15
|
+
/** The fit values Cloudflare Images accepts. A variant whose fit is set to anything else is rejected. */
|
|
16
|
+
const FIT_VALUES = new Set(['scale-down', 'contain', 'cover', 'crop', 'pad']);
|
|
17
|
+
/** The named gravity keywords Cloudflare Images accepts. A gravity is also valid as a coordinate
|
|
18
|
+
* string; everything else is rejected. */
|
|
19
|
+
const GRAVITY_KEYWORDS = new Set([
|
|
20
|
+
'auto',
|
|
21
|
+
'face',
|
|
22
|
+
'left',
|
|
23
|
+
'right',
|
|
24
|
+
'top',
|
|
25
|
+
'bottom',
|
|
26
|
+
'center',
|
|
27
|
+
]);
|
|
28
|
+
/** A gravity coordinate string, e.g. "0.5x0.5". */
|
|
29
|
+
const GRAVITY_COORD_RE = /^\d+(\.\d+)?x\d+(\.\d+)?$/;
|
|
30
|
+
/** Validate one variant's fit and gravity, throwing a cairn:-prefixed error naming the offending
|
|
31
|
+
* preset and value. The type system collapses VariantSpec.gravity to string, so the gravity check
|
|
32
|
+
* is the only guard against a bogus value reaching the transform URL. */
|
|
33
|
+
function validateVariant(name, spec) {
|
|
34
|
+
if (spec.fit !== undefined && !FIT_VALUES.has(spec.fit)) {
|
|
35
|
+
throw new Error(`cairn: media variant "${name}" has an unknown fit "${spec.fit}"`);
|
|
36
|
+
}
|
|
37
|
+
if (spec.gravity !== undefined &&
|
|
38
|
+
!GRAVITY_KEYWORDS.has(spec.gravity) &&
|
|
39
|
+
!GRAVITY_COORD_RE.test(spec.gravity)) {
|
|
40
|
+
throw new Error(`cairn: media variant "${name}" has an unknown gravity "${spec.gravity}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Validate a site's AssetConfig and resolve it into a ResolvedAssetConfig. An undefined block leaves
|
|
44
|
+
* media off and returns `{ enabled: false }` rather than throwing. A declared block must name its R2
|
|
45
|
+
* bucket and carry a known urlForm and valid variant fit and gravity values; each failure throws a
|
|
46
|
+
* cairn:-prefixed error. The named variants merge over the built-in presets. */
|
|
47
|
+
export function normalizeAssets(assets) {
|
|
48
|
+
if (assets === undefined)
|
|
49
|
+
return { enabled: false };
|
|
50
|
+
if (!assets.bucketBinding) {
|
|
51
|
+
throw new Error('cairn: a media assets block must name its R2 bucket binding');
|
|
52
|
+
}
|
|
53
|
+
if (assets.urlForm !== undefined && assets.urlForm !== 'slug' && assets.urlForm !== 'opaque') {
|
|
54
|
+
throw new Error(`cairn: media urlForm must be "slug" or "opaque", got "${assets.urlForm}"`);
|
|
55
|
+
}
|
|
56
|
+
for (const [name, spec] of Object.entries(assets.variants ?? {})) {
|
|
57
|
+
validateVariant(name, spec);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
enabled: true,
|
|
61
|
+
bucketBinding: assets.bucketBinding,
|
|
62
|
+
publicBase: assets.publicBase ?? DEFAULT_PUBLIC_BASE,
|
|
63
|
+
urlForm: assets.urlForm ?? 'slug',
|
|
64
|
+
maxUploadBytes: assets.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES,
|
|
65
|
+
allowedTypes: assets.allowedTypes ?? DEFAULT_ALLOWED_TYPES,
|
|
66
|
+
variants: { ...BUILT_IN_PRESETS, ...(assets.variants ?? {}) },
|
|
67
|
+
transformations: assets.transformations ?? false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A stored object without its body: the shape an `If-None-Match` hit or a metadata read returns. */
|
|
2
|
+
export interface DeliveryObject {
|
|
3
|
+
/** Writes the stored HTTP metadata (Content-Type, Cache-Control, and so on) onto `headers`. */
|
|
4
|
+
writeHttpMetadata(headers: Headers): void;
|
|
5
|
+
/** The strong validator R2 stored for the bytes, set as the response `ETag`. */
|
|
6
|
+
httpEtag: string;
|
|
7
|
+
/** The full object size in bytes, the denominator of a `Content-Range`. */
|
|
8
|
+
size: number;
|
|
9
|
+
/** Present only on a ranged read: the served window, used to build the `Content-Range`. R2 fills
|
|
10
|
+
* both fields for a `bytes=start-end` request; each is typed optional so the route derives the
|
|
11
|
+
* range bounds defensively against `size`. */
|
|
12
|
+
range?: {
|
|
13
|
+
offset?: number;
|
|
14
|
+
length?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** A stored object with its readable body, the shape a full or ranged read returns. */
|
|
18
|
+
export interface DeliveryObjectBody extends DeliveryObject {
|
|
19
|
+
body: ReadableStream;
|
|
20
|
+
}
|
|
21
|
+
/** The bucket surface the delivery route reads: a single conditional, optionally ranged, get. */
|
|
22
|
+
export interface DeliveryBucket {
|
|
23
|
+
get(key: string, opts?: {
|
|
24
|
+
/** R2 reads `If-None-Match`/`If-Match` from a passed `Headers`; the route forwards the request's. */
|
|
25
|
+
onlyIf?: {
|
|
26
|
+
etagDoesNotMatch?: string;
|
|
27
|
+
} | Headers;
|
|
28
|
+
/** The byte window to serve; the route parses it from the request `Range` header. */
|
|
29
|
+
range?: {
|
|
30
|
+
offset?: number;
|
|
31
|
+
length?: number;
|
|
32
|
+
} | Headers;
|
|
33
|
+
}): Promise<DeliveryObjectBody | DeliveryObject | null>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A narrow structural seam over the R2 bucket, modelling only what the delivery route reads.
|
|
2
|
+
//
|
|
3
|
+
// The route needs conditional and ranged gets, which the narrow MediaStore seam cannot express, so
|
|
4
|
+
// it talks to the raw bucket. It must not name any `@cloudflare/workers-types` type (`R2Bucket`,
|
|
5
|
+
// `R2Object`, `R2ObjectBody`, `R2HTTPMetadata`): that package is a devDependency the engine builds
|
|
6
|
+
// against but a consumer does not have, so any such name in a public `.d.ts` would break a consumer
|
|
7
|
+
// build that lacks `skipLibCheck`. `Headers`, `ReadableStream`, and `Response` are web globals, not
|
|
8
|
+
// workers-types, so they are safe on the public surface. `requireBucket` casts the real R2 binding
|
|
9
|
+
// to `DeliveryBucket` through `unknown`; the shapes below are a structural subset of the real R2 API.
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { normalizeAssets, type ResolvedAssetConfig } from './config.js';
|
|
2
|
+
export { parseMediaManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, type MediaEntry, type MediaManifest, } from './manifest.js';
|
|
3
|
+
export { hashBytes, shortHash, slugifyFilename, r2Key, publicPath } from './naming.js';
|
|
4
|
+
export { presetUrl, variantUrl, type VariantSpec } from './transform-url.js';
|
|
5
|
+
export { parseMediaToken, mediaToken, type MediaRef } from './reference.js';
|
|
6
|
+
export { makeMediaResolver, manifestMediaResolver, type MediaResolve } from '../render/resolve-media.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// cairn-cms: the node-safe `/media` public barrel. It re-exports only the pure media surface a site
|
|
2
|
+
// reaches outside the SvelteKit runtime: the config normalizer, the manifest functions, the naming
|
|
3
|
+
// and transform-URL helpers, the reference codec, and the render resolver. Nothing here pulls
|
|
4
|
+
// `@sveltejs/kit` or `@cloudflare/workers-types` into the module graph, so a plain-Node tool or a
|
|
5
|
+
// build step can import it. The R2-touching pieces (`store.ts`, `delivery-bucket.ts`) and the
|
|
6
|
+
// delivery-route factory and `requireBucket` stay on `/sveltekit`, off this surface, so the public
|
|
7
|
+
// `.d.ts` for `/media` names no kit or workers-types type.
|
|
8
|
+
export { normalizeAssets } from './config.js';
|
|
9
|
+
export { parseMediaManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, } from './manifest.js';
|
|
10
|
+
export { hashBytes, shortHash, slugifyFilename, r2Key, publicPath } from './naming.js';
|
|
11
|
+
export { presetUrl, variantUrl } from './transform-url.js';
|
|
12
|
+
export { parseMediaToken, mediaToken } from './reference.js';
|
|
13
|
+
export { makeMediaResolver, manifestMediaResolver } from '../render/resolve-media.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MediaEntry } from './manifest.js';
|
|
2
|
+
/** One stored asset in the picker's projected library, keyed elsewhere by the 16-hex content hash. */
|
|
3
|
+
export interface MediaLibraryEntry {
|
|
4
|
+
/** The 16-hex content-hash prefix that names the bytes. */
|
|
5
|
+
hash: string;
|
|
6
|
+
/** The cosmetic display slug in the media: token and the delivery path. */
|
|
7
|
+
slug: string;
|
|
8
|
+
/** The bare file extension (no dot), for example `webp`. */
|
|
9
|
+
ext: string;
|
|
10
|
+
/** The stored MIME type, for example `image/webp`; its top-level part drives the type facet. */
|
|
11
|
+
contentType: string;
|
|
12
|
+
/** The editable human name shown on the row. */
|
|
13
|
+
displayName: string;
|
|
14
|
+
/** The manifest alt, prefilled into a new placement; empty is the needs-alt signal. */
|
|
15
|
+
alt: string;
|
|
16
|
+
/** The pixel width, or null when the manifest carries none. */
|
|
17
|
+
width: number | null;
|
|
18
|
+
/** The pixel height, or null when the manifest carries none. */
|
|
19
|
+
height: number | null;
|
|
20
|
+
/** The stored byte size. */
|
|
21
|
+
bytes: number;
|
|
22
|
+
/** The ISO timestamp the bytes were first stored, the Library's sortable "Added" column. */
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
/** The projected library keyed by the 16-hex content hash, exactly EditData's `mediaLibrary`. */
|
|
26
|
+
export type MediaLibrary = Record<string, MediaLibraryEntry>;
|
|
27
|
+
/** Project a stored MediaEntry to the picker's MediaLibraryEntry, copying every display field and
|
|
28
|
+
* dropping the source-only sha256 and original filename. The single projection editLoad and
|
|
29
|
+
* mediaLibraryLoad both call, so the popover and the Library never diverge on the shared shape. */
|
|
30
|
+
export declare function mediaLibraryEntry(entry: MediaEntry): MediaLibraryEntry;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Project a stored MediaEntry to the picker's MediaLibraryEntry, copying every display field and
|
|
2
|
+
* dropping the source-only sha256 and original filename. The single projection editLoad and
|
|
3
|
+
* mediaLibraryLoad both call, so the popover and the Library never diverge on the shared shape. */
|
|
4
|
+
export function mediaLibraryEntry(entry) {
|
|
5
|
+
return {
|
|
6
|
+
hash: entry.hash,
|
|
7
|
+
slug: entry.slug,
|
|
8
|
+
ext: entry.ext,
|
|
9
|
+
contentType: entry.contentType,
|
|
10
|
+
displayName: entry.displayName,
|
|
11
|
+
alt: entry.alt,
|
|
12
|
+
width: entry.width,
|
|
13
|
+
height: entry.height,
|
|
14
|
+
bytes: entry.bytes,
|
|
15
|
+
createdAt: entry.createdAt,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** One stored asset's row: its content hash, its human layer, and its byte and pixel facts. The
|
|
2
|
+
* `contentType` is the stored MIME type, so the delivery route serves it verbatim rather than
|
|
3
|
+
* guessing from the extension. `width` and `height` are null when no dimensions are known (the
|
|
4
|
+
* client is the only dimension source and a Worker cannot re-derive them). */
|
|
5
|
+
export interface MediaEntry {
|
|
6
|
+
hash: string;
|
|
7
|
+
sha256: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
displayName: string;
|
|
10
|
+
originalFilename: string;
|
|
11
|
+
alt: string;
|
|
12
|
+
ext: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
width: number | null;
|
|
16
|
+
height: number | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
}
|
|
19
|
+
/** The whole stored-asset record, keyed by the 16-hex content-hash prefix. */
|
|
20
|
+
export type MediaManifest = Record<string, MediaEntry>;
|
|
21
|
+
/** Parse a committed media manifest. Tolerant: an empty, missing, null, or non-object input yields
|
|
22
|
+
* an empty manifest, so a first ingest into a site with no manifest file reads a clean {}. A valid
|
|
23
|
+
* object is returned as the manifest. */
|
|
24
|
+
export declare function parseMediaManifest(json: unknown): MediaManifest;
|
|
25
|
+
/** Parse the posted `media` field into a validated list of MediaEntry rows. The field arrives as a
|
|
26
|
+
* JSON string (the usual form-post shape), an already-parsed array, or junk. A string is JSON-parsed
|
|
27
|
+
* inside a try/catch that yields `[]` on a parse failure; a non-string array is taken directly;
|
|
28
|
+
* anything else yields `[]`. Each element is validated and a failing element is dropped, so a partly
|
|
29
|
+
* malformed post still lands its good rows. This is the trust boundary for the client's optimistic
|
|
30
|
+
* records. */
|
|
31
|
+
export declare function parseMediaEntries(value: unknown): MediaEntry[];
|
|
32
|
+
/** The dedup lookup: the entry stored under the content-hash prefix, or undefined when no bytes with
|
|
33
|
+
* that hash are stored yet. */
|
|
34
|
+
export declare function findByHash(manifest: MediaManifest, hash: string): MediaEntry | undefined;
|
|
35
|
+
/** Set the entry under its own hash, replacing any same-hash row. Returns a new manifest and leaves
|
|
36
|
+
* the input untouched, so a caller's prior manifest reference stays valid. The ingest path's patch. */
|
|
37
|
+
export declare function upsertMediaEntry(manifest: MediaManifest, entry: MediaEntry): MediaManifest;
|
|
38
|
+
/** Drop the entry under the given hash, returning a new manifest and leaving the input untouched.
|
|
39
|
+
* Removing an absent hash is a no-op that still returns an equivalent new manifest. The safe-delete
|
|
40
|
+
* path's patch. */
|
|
41
|
+
export declare function removeMediaEntry(manifest: MediaManifest, hash: string): MediaManifest;
|
|
42
|
+
/** Serialize canonically: the top-level hash keys sorted ascending, two-space pretty, and a trailing
|
|
43
|
+
* newline, so the committed file diffs cleanly in a PR and a re-serialization is byte-identical. */
|
|
44
|
+
export declare function serializeMediaManifest(manifest: MediaManifest): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// cairn-cms: the media manifest, a small git-committed record with one row per stored asset. It
|
|
2
|
+
// carries the human layer that the bytes cannot (display name, alt text, original filename) and is
|
|
3
|
+
// the dedup lookup: an ingest checks the content-hash prefix here before storing, so the same bytes
|
|
4
|
+
// are never stored twice. It mirrors the content manifest in ../content/manifest.ts, keyed by the
|
|
5
|
+
// 16-hex content-hash prefix rather than concept and id.
|
|
6
|
+
/** Parse a committed media manifest. Tolerant: an empty, missing, null, or non-object input yields
|
|
7
|
+
* an empty manifest, so a first ingest into a site with no manifest file reads a clean {}. A valid
|
|
8
|
+
* object is returned as the manifest. */
|
|
9
|
+
export function parseMediaManifest(json) {
|
|
10
|
+
if (!json || typeof json !== 'object' || Array.isArray(json))
|
|
11
|
+
return {};
|
|
12
|
+
return json;
|
|
13
|
+
}
|
|
14
|
+
/** Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
|
|
15
|
+
* for an optimistic record the client re-posts: the upload action server-owned each field at
|
|
16
|
+
* creation, but a re-post is untrusted, so every field is re-checked. A `hash` must be the 16-hex
|
|
17
|
+
* content-hash prefix; the string fields must be strings; `bytes` must be finite; `width`/`height`
|
|
18
|
+
* must each be a number or null; `createdAt` must be a string. */
|
|
19
|
+
function validateMediaEntry(value) {
|
|
20
|
+
if (!value || typeof value !== 'object')
|
|
21
|
+
return undefined;
|
|
22
|
+
const e = value;
|
|
23
|
+
const isString = (v) => typeof v === 'string';
|
|
24
|
+
const isNumOrNull = (v) => v === null || typeof v === 'number';
|
|
25
|
+
if (typeof e.hash !== 'string' || !/^[0-9a-f]{16}$/.test(e.hash))
|
|
26
|
+
return undefined;
|
|
27
|
+
if (!isString(e.sha256))
|
|
28
|
+
return undefined;
|
|
29
|
+
if (!isString(e.slug) || !isString(e.displayName) || !isString(e.originalFilename))
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!isString(e.alt) || !isString(e.ext) || !isString(e.contentType))
|
|
32
|
+
return undefined;
|
|
33
|
+
if (typeof e.bytes !== 'number' || !Number.isFinite(e.bytes))
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!isNumOrNull(e.width) || !isNumOrNull(e.height))
|
|
36
|
+
return undefined;
|
|
37
|
+
if (!isString(e.createdAt))
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
hash: e.hash,
|
|
41
|
+
sha256: e.sha256,
|
|
42
|
+
slug: e.slug,
|
|
43
|
+
displayName: e.displayName,
|
|
44
|
+
originalFilename: e.originalFilename,
|
|
45
|
+
alt: e.alt,
|
|
46
|
+
ext: e.ext,
|
|
47
|
+
contentType: e.contentType,
|
|
48
|
+
bytes: e.bytes,
|
|
49
|
+
width: e.width,
|
|
50
|
+
height: e.height,
|
|
51
|
+
createdAt: e.createdAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Parse the posted `media` field into a validated list of MediaEntry rows. The field arrives as a
|
|
55
|
+
* JSON string (the usual form-post shape), an already-parsed array, or junk. A string is JSON-parsed
|
|
56
|
+
* inside a try/catch that yields `[]` on a parse failure; a non-string array is taken directly;
|
|
57
|
+
* anything else yields `[]`. Each element is validated and a failing element is dropped, so a partly
|
|
58
|
+
* malformed post still lands its good rows. This is the trust boundary for the client's optimistic
|
|
59
|
+
* records. */
|
|
60
|
+
export function parseMediaEntries(value) {
|
|
61
|
+
let raw = value;
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(value);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(raw))
|
|
71
|
+
return [];
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
const entry = validateMediaEntry(item);
|
|
75
|
+
if (entry)
|
|
76
|
+
entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
/** The dedup lookup: the entry stored under the content-hash prefix, or undefined when no bytes with
|
|
81
|
+
* that hash are stored yet. */
|
|
82
|
+
export function findByHash(manifest, hash) {
|
|
83
|
+
return manifest[hash];
|
|
84
|
+
}
|
|
85
|
+
/** Set the entry under its own hash, replacing any same-hash row. Returns a new manifest and leaves
|
|
86
|
+
* the input untouched, so a caller's prior manifest reference stays valid. The ingest path's patch. */
|
|
87
|
+
export function upsertMediaEntry(manifest, entry) {
|
|
88
|
+
return { ...manifest, [entry.hash]: entry };
|
|
89
|
+
}
|
|
90
|
+
/** Drop the entry under the given hash, returning a new manifest and leaving the input untouched.
|
|
91
|
+
* Removing an absent hash is a no-op that still returns an equivalent new manifest. The safe-delete
|
|
92
|
+
* path's patch. */
|
|
93
|
+
export function removeMediaEntry(manifest, hash) {
|
|
94
|
+
const { [hash]: _removed, ...rest } = manifest;
|
|
95
|
+
return rest;
|
|
96
|
+
}
|
|
97
|
+
/** Serialize canonically: the top-level hash keys sorted ascending, two-space pretty, and a trailing
|
|
98
|
+
* newline, so the committed file diffs cleanly in a PR and a re-serialization is byte-identical. */
|
|
99
|
+
export function serializeMediaManifest(manifest) {
|
|
100
|
+
const sorted = {};
|
|
101
|
+
for (const hash of Object.keys(manifest).sort()) {
|
|
102
|
+
sorted[hash] = manifest[hash];
|
|
103
|
+
}
|
|
104
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The full lowercase hex sha256 of the bytes, via Web Crypto, hand-formatted to 64 hex chars. */
|
|
2
|
+
export declare function hashBytes(bytes: Uint8Array): Promise<string>;
|
|
3
|
+
/** The first 16 characters of a full hex digest, the content-hash prefix media references commit to. */
|
|
4
|
+
export declare function shortHash(full: string): string;
|
|
5
|
+
/** The strict ingest transform from a raw filename to a slug that satisfies the media: slug grammar,
|
|
6
|
+
* or the literal `file`. Drops the extension, lowercases, transliterates accents, collapses non-alphanumeric runs
|
|
7
|
+
* to a single hyphen, trims, caps at 80 chars, screens Windows reserved names, and falls back to
|
|
8
|
+
* `file` when nothing usable is left. */
|
|
9
|
+
export declare function slugifyFilename(name: string): string;
|
|
10
|
+
/** The content-addressed R2 object key `media/<aa>/<shortHash>.<ext>`, fanned out on the first two
|
|
11
|
+
* hex chars of the short hash. No leading slash: this is an object key, not a URL. `ext` is bare
|
|
12
|
+
* (no dot), for example `webp`. */
|
|
13
|
+
export declare function r2Key(shortHash: string, ext: string): string;
|
|
14
|
+
/** The public delivery URL path, with a leading slash, under the delivery base (`publicBase`,
|
|
15
|
+
* default `/media`). The `slug` form is human-readable (`<base>/<slug>.<shortHash>.<ext>`, or
|
|
16
|
+
* `<base>/<shortHash>.<ext>` when the slug is null); the `opaque` form mirrors the R2 fan-out
|
|
17
|
+
* (`<base>/<aa>/<shortHash>.<ext>`) and ignores the slug. */
|
|
18
|
+
export declare function publicPath(slug: string | null, shortHash: string, ext: string, urlForm: 'slug' | 'opaque', publicBase?: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// cairn-cms: media naming. Media is content-addressed: the sha256 of the bytes names the object, so
|
|
2
|
+
// the same bytes always land at the same key no matter the original filename. This module owns the
|
|
3
|
+
// hash, the ingest slug transform, the R2 object key, and the public delivery path. The slug grammar
|
|
4
|
+
// here matches the one parseMediaToken validates in ./reference.ts, so an ingested filename round
|
|
5
|
+
// trips through the media: token unchanged.
|
|
6
|
+
// slugifyFilename output always satisfies parseMediaToken's grammar (lowercase alphanumerics joined
|
|
7
|
+
// by single internal hyphens, no leading or trailing hyphen), or is the literal `file`.
|
|
8
|
+
/** Combining marks (Unicode block U+0300 to U+036F), left over after an NFD decompose, stripped to
|
|
9
|
+
* fold an accented letter down to its ASCII base. Written as escapes because the literal marks are
|
|
10
|
+
* invisible in source. */
|
|
11
|
+
const COMBINING_MARKS = /[\u0300-\u036f]/g;
|
|
12
|
+
/** Windows reserved device names. A bare match (case-insensitive) cannot survive as the slug, since
|
|
13
|
+
* it names a device rather than a file on that platform. */
|
|
14
|
+
const RESERVED = new Set([
|
|
15
|
+
'con',
|
|
16
|
+
'prn',
|
|
17
|
+
'aux',
|
|
18
|
+
'nul',
|
|
19
|
+
'com1',
|
|
20
|
+
'com2',
|
|
21
|
+
'com3',
|
|
22
|
+
'com4',
|
|
23
|
+
'com5',
|
|
24
|
+
'com6',
|
|
25
|
+
'com7',
|
|
26
|
+
'com8',
|
|
27
|
+
'com9',
|
|
28
|
+
'lpt1',
|
|
29
|
+
'lpt2',
|
|
30
|
+
'lpt3',
|
|
31
|
+
'lpt4',
|
|
32
|
+
'lpt5',
|
|
33
|
+
'lpt6',
|
|
34
|
+
'lpt7',
|
|
35
|
+
'lpt8',
|
|
36
|
+
'lpt9',
|
|
37
|
+
]);
|
|
38
|
+
/** The maximum slug length, applied before the reserved-name and empty fallbacks. */
|
|
39
|
+
const MAX_SLUG = 80;
|
|
40
|
+
/** A 16-character lowercase hex content-hash prefix, the bare-hash reference form. A slug that
|
|
41
|
+
* matches this shape would collide with `media:<hash>`, so slugifyFilename screens it. */
|
|
42
|
+
const HASH_RE = /^[0-9a-f]{16}$/;
|
|
43
|
+
/** A short alphanumeric extension (no dot), the only shape r2Key accepts, for example `webp`. */
|
|
44
|
+
const R2_EXT_RE = /^[a-z0-9]{1,5}$/;
|
|
45
|
+
// A Uint8Array's generic buffer type no longer satisfies Web Crypto's BufferSource under strict lib
|
|
46
|
+
// types, since the backing buffer may be a SharedArrayBuffer; slice the bytes into a plain
|
|
47
|
+
// ArrayBuffer to hand digest. Mirrors the buf helper in ../github/signing.ts.
|
|
48
|
+
function asArrayBuffer(bytes) {
|
|
49
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
50
|
+
}
|
|
51
|
+
/** The full lowercase hex sha256 of the bytes, via Web Crypto, hand-formatted to 64 hex chars. */
|
|
52
|
+
export async function hashBytes(bytes) {
|
|
53
|
+
const digest = await crypto.subtle.digest('SHA-256', asArrayBuffer(bytes));
|
|
54
|
+
return Array.from(new Uint8Array(digest))
|
|
55
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
/** The first 16 characters of a full hex digest, the content-hash prefix media references commit to. */
|
|
59
|
+
export function shortHash(full) {
|
|
60
|
+
return full.slice(0, 16);
|
|
61
|
+
}
|
|
62
|
+
/** The strict ingest transform from a raw filename to a slug that satisfies the media: slug grammar,
|
|
63
|
+
* or the literal `file`. Drops the extension, lowercases, transliterates accents, collapses non-alphanumeric runs
|
|
64
|
+
* to a single hyphen, trims, caps at 80 chars, screens Windows reserved names, and falls back to
|
|
65
|
+
* `file` when nothing usable is left. */
|
|
66
|
+
export function slugifyFilename(name) {
|
|
67
|
+
const dot = name.lastIndexOf('.');
|
|
68
|
+
const stem = dot === -1 ? name : name.slice(0, dot);
|
|
69
|
+
let slug = stem
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.normalize('NFD')
|
|
72
|
+
.replace(COMBINING_MARKS, '')
|
|
73
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '');
|
|
75
|
+
if (slug.length > MAX_SLUG) {
|
|
76
|
+
slug = slug.slice(0, MAX_SLUG).replace(/-+$/, '');
|
|
77
|
+
}
|
|
78
|
+
if (RESERVED.has(slug))
|
|
79
|
+
return `${slug}-file`;
|
|
80
|
+
if (slug === '')
|
|
81
|
+
return 'file';
|
|
82
|
+
// A slug shaped like a bare 16-hex hash would collide with the `media:<hash>` reference form, so
|
|
83
|
+
// append -img (mirroring the reserved-name -file fallback) to keep the slug and bare-hash forms
|
|
84
|
+
// disjoint.
|
|
85
|
+
if (HASH_RE.test(slug))
|
|
86
|
+
return `${slug}-img`;
|
|
87
|
+
return slug;
|
|
88
|
+
}
|
|
89
|
+
/** The content-addressed R2 object key `media/<aa>/<shortHash>.<ext>`, fanned out on the first two
|
|
90
|
+
* hex chars of the short hash. No leading slash: this is an object key, not a URL. `ext` is bare
|
|
91
|
+
* (no dot), for example `webp`. */
|
|
92
|
+
export function r2Key(shortHash, ext) {
|
|
93
|
+
if (!HASH_RE.test(shortHash)) {
|
|
94
|
+
throw new Error(`r2Key: hash must be 16 lowercase hex chars, got "${shortHash}"`);
|
|
95
|
+
}
|
|
96
|
+
if (!R2_EXT_RE.test(ext)) {
|
|
97
|
+
throw new Error(`r2Key: ext must be 1 to 5 lowercase alphanumerics, got "${ext}"`);
|
|
98
|
+
}
|
|
99
|
+
return `media/${shortHash.slice(0, 2)}/${shortHash}.${ext}`;
|
|
100
|
+
}
|
|
101
|
+
/** The public delivery URL path, with a leading slash, under the delivery base (`publicBase`,
|
|
102
|
+
* default `/media`). The `slug` form is human-readable (`<base>/<slug>.<shortHash>.<ext>`, or
|
|
103
|
+
* `<base>/<shortHash>.<ext>` when the slug is null); the `opaque` form mirrors the R2 fan-out
|
|
104
|
+
* (`<base>/<aa>/<shortHash>.<ext>`) and ignores the slug. */
|
|
105
|
+
export function publicPath(slug, shortHash, ext, urlForm, publicBase = '/media') {
|
|
106
|
+
if (urlForm === 'opaque') {
|
|
107
|
+
return `${publicBase}/${shortHash.slice(0, 2)}/${shortHash}.${ext}`;
|
|
108
|
+
}
|
|
109
|
+
return slug === null
|
|
110
|
+
? `${publicBase}/${shortHash}.${ext}`
|
|
111
|
+
: `${publicBase}/${slug}.${shortHash}.${ext}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MediaManifest } from './manifest.js';
|
|
2
|
+
/** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
|
|
3
|
+
* has no manifest row; `missingObjects` are manifest hashes with no stored object. */
|
|
4
|
+
export interface ReconcileResult {
|
|
5
|
+
/** Stored keys (full R2 keys) whose content hash is absent from the manifest. */
|
|
6
|
+
orphanedObjects: string[];
|
|
7
|
+
/** Manifest content-hash keys with no matching stored object. */
|
|
8
|
+
missingObjects: string[];
|
|
9
|
+
}
|
|
10
|
+
/** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
|
|
11
|
+
* both orphan directions. A stored key that does not match the media-key grammar is ignored, since
|
|
12
|
+
* it is not a content-addressed media object this reconcile owns. */
|
|
13
|
+
export declare function reconcileMedia(storedKeys: string[], manifest: MediaManifest): ReconcileResult;
|
|
14
|
+
/** One page of an R2 list, the narrow subset the reconcile read consumes. */
|
|
15
|
+
interface ReconcileListPage {
|
|
16
|
+
objects: {
|
|
17
|
+
key: string;
|
|
18
|
+
}[];
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
cursor?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The R2 bucket surface the reconcile read needs: a single prefixed, paginated list. A local
|
|
23
|
+
* structural interface so no @cloudflare/workers-types name is imported (the module is internal and
|
|
24
|
+
* on no public subpath, but the narrow seam keeps the build self-contained either way). */
|
|
25
|
+
export interface ReconcileBucket {
|
|
26
|
+
list(opts?: {
|
|
27
|
+
prefix?: string;
|
|
28
|
+
cursor?: string;
|
|
29
|
+
}): Promise<ReconcileListPage>;
|
|
30
|
+
}
|
|
31
|
+
/** The glue runner: list every stored key under the media/ prefix (paginating through R2's
|
|
32
|
+
* cursor/truncated), reconcile against the manifest, log the count summary, and return the result.
|
|
33
|
+
* The log record carries counts only, never bytes or a key list; the keys are content hashes and so
|
|
34
|
+
* carry no PII, but the count summary is all an operator needs to size the orphan state. */
|
|
35
|
+
export declare function runReconcile(bucket: ReconcileBucket, manifest: MediaManifest): Promise<ReconcileResult>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { log } from '../log/index.js';
|
|
2
|
+
/** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
|
|
3
|
+
const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
|
|
4
|
+
/** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
|
|
5
|
+
* both orphan directions. A stored key that does not match the media-key grammar is ignored, since
|
|
6
|
+
* it is not a content-addressed media object this reconcile owns. */
|
|
7
|
+
export function reconcileMedia(storedKeys, manifest) {
|
|
8
|
+
const manifestHashes = new Set(Object.keys(manifest));
|
|
9
|
+
const storedHashes = new Set();
|
|
10
|
+
const orphanedObjects = [];
|
|
11
|
+
for (const key of storedKeys) {
|
|
12
|
+
const hash = MEDIA_KEY_RE.exec(key)?.[1];
|
|
13
|
+
if (hash === undefined)
|
|
14
|
+
continue;
|
|
15
|
+
storedHashes.add(hash);
|
|
16
|
+
if (!manifestHashes.has(hash))
|
|
17
|
+
orphanedObjects.push(key);
|
|
18
|
+
}
|
|
19
|
+
const missingObjects = [];
|
|
20
|
+
for (const hash of manifestHashes) {
|
|
21
|
+
if (!storedHashes.has(hash))
|
|
22
|
+
missingObjects.push(hash);
|
|
23
|
+
}
|
|
24
|
+
return { orphanedObjects, missingObjects };
|
|
25
|
+
}
|
|
26
|
+
/** The glue runner: list every stored key under the media/ prefix (paginating through R2's
|
|
27
|
+
* cursor/truncated), reconcile against the manifest, log the count summary, and return the result.
|
|
28
|
+
* The log record carries counts only, never bytes or a key list; the keys are content hashes and so
|
|
29
|
+
* carry no PII, but the count summary is all an operator needs to size the orphan state. */
|
|
30
|
+
export async function runReconcile(bucket, manifest) {
|
|
31
|
+
const storedKeys = [];
|
|
32
|
+
let cursor;
|
|
33
|
+
do {
|
|
34
|
+
const page = await bucket.list({ prefix: 'media/', cursor });
|
|
35
|
+
for (const object of page.objects)
|
|
36
|
+
storedKeys.push(object.key);
|
|
37
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
38
|
+
} while (cursor !== undefined);
|
|
39
|
+
const result = reconcileMedia(storedKeys, manifest);
|
|
40
|
+
log.info('media.orphan_reconcile', {
|
|
41
|
+
orphaned: result.orphanedObjects.length,
|
|
42
|
+
missing: result.missingObjects.length,
|
|
43
|
+
});
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A resolved reference to a media asset by its content-hash prefix, with an optional display slug. */
|
|
2
|
+
export interface MediaRef {
|
|
3
|
+
slug: string | null;
|
|
4
|
+
hash: string;
|
|
5
|
+
}
|
|
6
|
+
/** Parse a `media:<slug>.<hash>` href (or the bare `media:<hash>` form), or null for any other
|
|
7
|
+
* href or a malformed token. Splits on the last dot, so a slug that illegally contains a dot fails
|
|
8
|
+
* the slug grammar and returns null. */
|
|
9
|
+
export declare function parseMediaToken(href: string): MediaRef | null;
|
|
10
|
+
/** Write the canonical media: token for a ref. The inverse of parseMediaToken, so a parse then
|
|
11
|
+
* write round trip is stable: `media:<slug>.<hash>` when the slug is present, else `media:<hash>`. */
|
|
12
|
+
export declare function mediaToken(ref: MediaRef): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// cairn-cms: the media: reference token. A media reference is the logical handle that content
|
|
2
|
+
// commits to git, keyed to a content-hash prefix so the same bytes resolve no matter where they
|
|
3
|
+
// are stored or what they are named. The canonical form is `media:<slug>.<hash>`: the hash is a
|
|
4
|
+
// 16-character lowercase hex content-hash prefix that identifies the bytes, and the slug is a
|
|
5
|
+
// cosmetic display name. The bare `media:<hash>` form (no slug) is also valid. This module owns
|
|
6
|
+
// the grammar; it mirrors the cairn: link codec in ../content/links.ts.
|
|
7
|
+
/** A 16-character lowercase hex content-hash prefix. */
|
|
8
|
+
const HASH_RE = /^[0-9a-f]{16}$/;
|
|
9
|
+
/** The slug grammar from the Task 2 slugify transform: lowercase alphanumerics joined by single
|
|
10
|
+
* internal hyphens, with no leading or trailing hyphen and no dot (the dot is the slug/hash
|
|
11
|
+
* separator). */
|
|
12
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
|
+
/** Parse a `media:<slug>.<hash>` href (or the bare `media:<hash>` form), or null for any other
|
|
14
|
+
* href or a malformed token. Splits on the last dot, so a slug that illegally contains a dot fails
|
|
15
|
+
* the slug grammar and returns null. */
|
|
16
|
+
export function parseMediaToken(href) {
|
|
17
|
+
if (!href.startsWith('media:'))
|
|
18
|
+
return null;
|
|
19
|
+
const rest = href.slice('media:'.length);
|
|
20
|
+
const dot = rest.lastIndexOf('.');
|
|
21
|
+
if (dot === -1)
|
|
22
|
+
return HASH_RE.test(rest) ? { slug: null, hash: rest } : null;
|
|
23
|
+
const slug = rest.slice(0, dot);
|
|
24
|
+
const hash = rest.slice(dot + 1);
|
|
25
|
+
if (!HASH_RE.test(hash) || !SLUG_RE.test(slug))
|
|
26
|
+
return null;
|
|
27
|
+
return { slug, hash };
|
|
28
|
+
}
|
|
29
|
+
/** Write the canonical media: token for a ref. The inverse of parseMediaToken, so a parse then
|
|
30
|
+
* write round trip is stable: `media:<slug>.<hash>` when the slug is present, else `media:<hash>`. */
|
|
31
|
+
export function mediaToken(ref) {
|
|
32
|
+
return ref.slug === null ? `media:${ref.hash}` : `media:${ref.slug}.${ref.hash}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect the MIME type of an image from its leading magic bytes. Reads only the first ~32 bytes and
|
|
3
|
+
* returns the recognized type, or null for an unrecognized magic or an input too short for a given
|
|
4
|
+
* check. This is the server's source of truth for an upload's type; the client-declared type is
|
|
5
|
+
* advisory. Recognizes JPEG, PNG, GIF, WebP, and the AVIF/HEIC ISO-BMFF brands.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sniffMediaType(bytes: Uint8Array): string | null;
|
|
8
|
+
/** The storage extension for a sniffed media type, or null for a type the upload path does not store
|
|
9
|
+
* (HEIC, an unknown type). Driven by the sniffed type, so the key's ext is server-owned. */
|
|
10
|
+
export declare function extForMediaType(type: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* The engine-level upload deny predicate. Returns true (reject) when the upload is markup a site can
|
|
13
|
+
* never override: a declared type of image/svg+xml, image/svg, text/html, or application/xml, OR a
|
|
14
|
+
* payload whose first non-whitespace byte is `<` (an 0x3C after skipping leading ASCII whitespace).
|
|
15
|
+
* This runs ahead of and independent of any site `allowedTypes`, since SVG and HTML carry active
|
|
16
|
+
* content. The byte check catches a markup payload sent under a permitted declared type.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isDeniedUpload(bytes: Uint8Array, declaredType?: string): boolean;
|