@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
|
@@ -46,6 +46,24 @@ export function issueCsrfToken(event: { url: URL; cookies: CookieJar }): string
|
|
|
46
46
|
return token;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Validate the double-submit token on a raw-body upload POST, reading the submitted token from the
|
|
51
|
+
* `X-Cairn-CSRF` request header rather than a form field. The upload's file bytes are the request
|
|
52
|
+
* body and are read once, so the form-field path (which clones the body to read `formData`) does not
|
|
53
|
+
* apply; the action carries the CSRF authority for uploads instead. Compares the header against the
|
|
54
|
+
* csrf cookie the loads issue, constant-time.
|
|
55
|
+
*
|
|
56
|
+
* Security rests on a custom request header being unsettable cross-origin without a CORS preflight:
|
|
57
|
+
* never add a permissive `Access-Control-Allow-Headers: x-cairn-csrf` (or an allow-origin) for
|
|
58
|
+
* `/admin` or `/media`, or this header witness collapses.
|
|
59
|
+
*/
|
|
60
|
+
export function validateCsrfHeader(event: { url: URL; request: Request; cookies: CookieJar }): boolean {
|
|
61
|
+
const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
|
|
62
|
+
if (!cookie) return false;
|
|
63
|
+
const submitted = event.request.headers.get('x-cairn-csrf') ?? '';
|
|
64
|
+
return tokensMatch(submitted, cookie);
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
/** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
|
|
50
68
|
export async function validateCsrfToken(event: RequestContext): Promise<boolean> {
|
|
51
69
|
const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
-
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
7
|
+
import { isUnsafeFormRequest, originMatches, validateCsrfToken, validateCsrfHeader } from './csrf.js';
|
|
8
8
|
import { applySecurityHeaders } from './admin-response.js';
|
|
9
9
|
import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
@@ -75,8 +75,17 @@ export function createAuthGuard() {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
78
|
-
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
79
|
-
|
|
78
|
+
// 403 before resolve() runs. This covers the public login/auth posts too. The header witness is
|
|
79
|
+
// tried first: a valid X-Cairn-CSRF header clears the request without cloning the body, which is
|
|
80
|
+
// how the raw-body media upload (a text/plain POST) passes CSRF. A custom header cannot be set
|
|
81
|
+
// cross-origin without a CORS preflight, so it is as strong a token witness as the form field.
|
|
82
|
+
// Only with no valid header does the form-field path run and clone the body to read the token,
|
|
83
|
+
// the unchanged path for every ordinary admin form post.
|
|
84
|
+
if (
|
|
85
|
+
isUnsafeFormRequest(event.request) &&
|
|
86
|
+
!validateCsrfHeader(event) &&
|
|
87
|
+
!(await validateCsrfToken(event))
|
|
88
|
+
) {
|
|
80
89
|
log.warn('guard.rejected', { reason: 'csrf', path: pathname });
|
|
81
90
|
return renderConditionResponse('auth.csrf-token-invalid');
|
|
82
91
|
}
|
|
@@ -4,18 +4,24 @@ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
|
|
|
4
4
|
export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
|
|
5
5
|
export { createEditorRoutes } from './editors-routes.js';
|
|
6
6
|
export { createContentRoutes } from './content-routes.js';
|
|
7
|
+
export { createMediaRoute } from './media-route.js';
|
|
7
8
|
export type {
|
|
8
9
|
NavConcept,
|
|
9
10
|
LayoutData,
|
|
10
11
|
EntrySummary,
|
|
11
12
|
ListData,
|
|
12
13
|
EditData,
|
|
14
|
+
MediaUsageInfo,
|
|
15
|
+
MediaLibraryData,
|
|
13
16
|
ContentEvent,
|
|
14
17
|
ContentRoutesDeps,
|
|
15
18
|
SaveFailure,
|
|
16
19
|
DeleteRefusal,
|
|
17
20
|
RenameFailure,
|
|
21
|
+
MediaDeleteRefusal,
|
|
22
|
+
MediaUpdateFailure,
|
|
18
23
|
ContentFormFailure,
|
|
24
|
+
UploadResult,
|
|
19
25
|
} from './content-routes.js';
|
|
20
26
|
export { createNavRoutes } from './nav-routes.js';
|
|
21
27
|
export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The media delivery route: an engine-provided SvelteKit RequestHandler a site mounts at
|
|
2
|
+
// `/media/[...path]`. It streams content-addressed bytes from R2 with the security headers that are
|
|
3
|
+
// the load-bearing XSS control for served media. The route sits outside `/admin`, so the admin
|
|
4
|
+
// security headers never run on it; it owns its own.
|
|
5
|
+
//
|
|
6
|
+
// It lives on the `/sveltekit` barrel, not the node-safe `/media` subpath, because it reads
|
|
7
|
+
// `platform.env`, which pulls `@sveltejs/kit` into its graph. Its public signature names only kit
|
|
8
|
+
// (a peer dependency) and web globals, never an `@cloudflare/workers-types` type (decision 5).
|
|
9
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
10
|
+
import { requireBucket } from '../env.js';
|
|
11
|
+
import { CairnError } from '../diagnostics/index.js';
|
|
12
|
+
import { r2Key } from '../media/naming.js';
|
|
13
|
+
import { log } from '../log/index.js';
|
|
14
|
+
import type { DeliveryObject, DeliveryObjectBody } from '../media/delivery-bucket.js';
|
|
15
|
+
import type { ResolvedAssetConfig } from '../media/config.js';
|
|
16
|
+
|
|
17
|
+
/** A 16-character lowercase hex content-hash prefix, validated before any R2 lookup. */
|
|
18
|
+
const HASH_RE = /^[0-9a-f]{16}$/;
|
|
19
|
+
|
|
20
|
+
/** The closed delivery extension allow-list. A filename ext outside this set is a 404 with no R2
|
|
21
|
+
* read, so the route can never serve a type it cannot vouch for. */
|
|
22
|
+
const DELIVERY_EXTS: ReadonlySet<string> = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']);
|
|
23
|
+
|
|
24
|
+
/** The load-bearing XSS control: set on every non-404 response, so a served object can never run as
|
|
25
|
+
* active content. `Content-Type` comes from the stored, server-validated metadata via
|
|
26
|
+
* `writeHttpMetadata`; these override or add to it. */
|
|
27
|
+
function applySecurityHeaders(headers: Headers, etag: string): void {
|
|
28
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
29
|
+
headers.set('Content-Disposition', 'inline');
|
|
30
|
+
headers.set('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
31
|
+
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
|
|
32
|
+
headers.set('ETag', etag);
|
|
33
|
+
// An object stored outside the upload pipeline (a manual put, a future import) may carry no
|
|
34
|
+
// content type, so writeHttpMetadata would set none. Pair `nosniff` with an explicit safe
|
|
35
|
+
// default rather than serving a typeless response.
|
|
36
|
+
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the returned object carries a body (a full or ranged read), narrowing it to the body
|
|
40
|
+
* variant. R2 returns a body-less object on an `If-None-Match` hit. */
|
|
41
|
+
function hasBody(obj: DeliveryObject | DeliveryObjectBody): obj is DeliveryObjectBody {
|
|
42
|
+
return 'body' in obj && (obj as DeliveryObjectBody).body != null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the media delivery `RequestHandler` for a site's resolved media config.
|
|
47
|
+
*
|
|
48
|
+
* The handler validates the hash and extension before any R2 call, derives the object key from the
|
|
49
|
+
* validated values only (never trusting the URL's fan-out), guards the Cloudflare Images self-loop,
|
|
50
|
+
* and sets the security headers on every served response.
|
|
51
|
+
*
|
|
52
|
+
* @param resolved the adapter's resolved media config; when media is off the handler always 404s.
|
|
53
|
+
*/
|
|
54
|
+
export function createMediaRoute(resolved: ResolvedAssetConfig): RequestHandler {
|
|
55
|
+
return async (event) => {
|
|
56
|
+
// Media off: the route is mounted but serves nothing.
|
|
57
|
+
if (!resolved.enabled) return new Response(null, { status: 404 });
|
|
58
|
+
|
|
59
|
+
// The catch-all param is conventionally `path` (route `/media/[...path]`). Decode each segment
|
|
60
|
+
// on its own and reject a traversal or an embedded slash, so no undecoded or `..` path reaches
|
|
61
|
+
// R2. params.path is `string | undefined` (kit's fallback ambient types), so guard the absence.
|
|
62
|
+
const raw = event.params.path;
|
|
63
|
+
if (typeof raw !== 'string' || raw === '') return new Response(null, { status: 404 });
|
|
64
|
+
const segments: string[] = [];
|
|
65
|
+
for (const part of raw.split('/')) {
|
|
66
|
+
let decoded: string;
|
|
67
|
+
try {
|
|
68
|
+
decoded = decodeURIComponent(part);
|
|
69
|
+
} catch {
|
|
70
|
+
return new Response(null, { status: 404 });
|
|
71
|
+
}
|
|
72
|
+
if (decoded === '' || decoded.includes('/') || decoded === '..') {
|
|
73
|
+
return new Response(null, { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
segments.push(decoded);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The filename is the last segment; its dot fields end with `<hash>.<ext>`. The slug, if any, is
|
|
79
|
+
// attacker-controlled and ignored. parseMediaToken does not run here.
|
|
80
|
+
const filename = segments[segments.length - 1];
|
|
81
|
+
const fields = filename.split('.');
|
|
82
|
+
if (fields.length < 2) return new Response(null, { status: 404 });
|
|
83
|
+
const ext = fields[fields.length - 1].toLowerCase();
|
|
84
|
+
const hash = fields[fields.length - 2];
|
|
85
|
+
|
|
86
|
+
// Validate before any R2 call: a bad hash or ext is a 404 with no read.
|
|
87
|
+
if (!HASH_RE.test(hash) || !DELIVERY_EXTS.has(ext)) {
|
|
88
|
+
return new Response(null, { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Derive the key from the validated values only; r2Key recomputes the fan-out from the hash.
|
|
92
|
+
const key = r2Key(hash, ext);
|
|
93
|
+
|
|
94
|
+
// Resolve the bucket. A missing binding is a drained 503 with a log, never a thrown 500.
|
|
95
|
+
let bucket;
|
|
96
|
+
try {
|
|
97
|
+
// `event.platform` is `App.Platform`, which the engine does not declare (a site does, with an
|
|
98
|
+
// `env`), so read it through a structural cast rather than naming the site's ambient type.
|
|
99
|
+
const platform = event.platform as { env?: Record<string, unknown> } | undefined;
|
|
100
|
+
bucket = requireBucket(platform?.env ?? {}, resolved.bucketBinding);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof CairnError && err.conditionId === 'config.bindings-missing') {
|
|
103
|
+
log.warn('media.delivery_failed', {
|
|
104
|
+
reason: 'binding-missing',
|
|
105
|
+
binding: resolved.bucketBinding,
|
|
106
|
+
});
|
|
107
|
+
return new Response(null, { status: 503 });
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Self-loop guard: the Cloudflare Images origin subrequest carries `Via: image-resizing`. Serve
|
|
113
|
+
// it a clean full-body 200 with no conditional or range handling, so a transform cannot loop.
|
|
114
|
+
const via = event.request.headers.get('Via') ?? '';
|
|
115
|
+
const isImageResizing = via.includes('image-resizing');
|
|
116
|
+
|
|
117
|
+
// Only forward `range` when the request actually carried a `Range` header. R2 populates the
|
|
118
|
+
// returned object's `.range` whenever a `range` option is passed (even a header-less one), so
|
|
119
|
+
// passing it unconditionally would turn every full GET into a 206.
|
|
120
|
+
const hasRangeRequest = !isImageResizing && event.request.headers.has('Range');
|
|
121
|
+
const getOpts = isImageResizing
|
|
122
|
+
? undefined
|
|
123
|
+
: {
|
|
124
|
+
onlyIf: event.request.headers,
|
|
125
|
+
...(hasRangeRequest ? { range: event.request.headers } : {}),
|
|
126
|
+
};
|
|
127
|
+
const obj = await bucket.get(key, getOpts);
|
|
128
|
+
|
|
129
|
+
if (obj === null) return new Response(null, { status: 404 });
|
|
130
|
+
|
|
131
|
+
// A body-less object is R2's `If-None-Match` hit: 304, no body. Skipped for the self-loop path,
|
|
132
|
+
// which always requested the full body.
|
|
133
|
+
if (!isImageResizing && !hasBody(obj)) {
|
|
134
|
+
const headers = new Headers();
|
|
135
|
+
obj.writeHttpMetadata(headers);
|
|
136
|
+
applySecurityHeaders(headers, obj.httpEtag);
|
|
137
|
+
return new Response(null, { status: 304, headers });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const headers = new Headers();
|
|
141
|
+
obj.writeHttpMetadata(headers);
|
|
142
|
+
applySecurityHeaders(headers, obj.httpEtag);
|
|
143
|
+
|
|
144
|
+
// A ranged read carries `obj.range`: respond 206 with a Content-Range. R2 fills the served
|
|
145
|
+
// window; derive the bounds defensively against the full size.
|
|
146
|
+
if (hasRangeRequest && obj.range) {
|
|
147
|
+
const start = obj.range.offset ?? 0;
|
|
148
|
+
const length = obj.range.length ?? obj.size - start;
|
|
149
|
+
const end = start + length - 1;
|
|
150
|
+
headers.set('Content-Range', `bytes ${start}-${end}/${obj.size}`);
|
|
151
|
+
const body = hasBody(obj) ? obj.body : null;
|
|
152
|
+
return new Response(body, { status: 206, headers });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const body = hasBody(obj) ? obj.body : null;
|
|
156
|
+
return new Response(body, { status: 200, headers });
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/lib/vite/index.ts
CHANGED
|
@@ -220,20 +220,26 @@ export interface AdapterFacts {
|
|
|
220
220
|
repo?: string;
|
|
221
221
|
/** `cairn.sender.from`. */
|
|
222
222
|
from?: string;
|
|
223
|
+
/** `cairn.assets.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
|
|
224
|
+
* assets. The doctor's conditional media-bucket check reads it. */
|
|
225
|
+
mediaBucketBinding?: string;
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
/** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
|
|
226
|
-
* configured config module and exports the string-typed `owner`, `repo`,
|
|
227
|
-
* nothing else of the adapter (least of all a secret) crosses the
|
|
229
|
+
* configured config module and exports the string-typed `owner`, `repo`, `from`, and the media
|
|
230
|
+
* `bucketBinding` as JSON, so nothing else of the adapter (least of all a secret) crosses the
|
|
231
|
+
* boundary. */
|
|
228
232
|
function adapterFactsSource(opts: CairnManifestOptions): string {
|
|
229
233
|
return `
|
|
230
234
|
import { cairn } from ${JSON.stringify(opts.configModule)};
|
|
231
235
|
const backend = cairn?.backend ?? {};
|
|
232
236
|
const sender = cairn?.sender ?? {};
|
|
237
|
+
const assets = cairn?.assets ?? {};
|
|
233
238
|
const facts = {};
|
|
234
239
|
if (typeof backend.owner === 'string') facts.owner = backend.owner;
|
|
235
240
|
if (typeof backend.repo === 'string') facts.repo = backend.repo;
|
|
236
241
|
if (typeof sender.from === 'string') facts.from = sender.from;
|
|
242
|
+
if (typeof assets.bucketBinding === 'string') facts.mediaBucketBinding = assets.bucketBinding;
|
|
237
243
|
export const result = JSON.stringify(facts);
|
|
238
244
|
`;
|
|
239
245
|
}
|
|
@@ -264,6 +270,7 @@ export async function readAdapterFacts(cwd: string = process.cwd()): Promise<Ada
|
|
|
264
270
|
if (typeof parsed.owner === 'string') facts.owner = parsed.owner;
|
|
265
271
|
if (typeof parsed.repo === 'string') facts.repo = parsed.repo;
|
|
266
272
|
if (typeof parsed.from === 'string') facts.from = parsed.from;
|
|
273
|
+
if (typeof parsed.mediaBucketBinding === 'string') facts.mediaBucketBinding = parsed.mediaBucketBinding;
|
|
267
274
|
return facts;
|
|
268
275
|
} catch {
|
|
269
276
|
return null;
|