caracms 0.5.1 → 0.7.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.
@@ -331,7 +331,9 @@ editor's uploads are) and prints one JSON array on stdout:
331
331
  ```
332
332
 
333
333
  - It reads `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID` and
334
- `S3_SECRET_ACCESS_KEY` from the environment, else the site's `.env`.
334
+ `S3_SECRET_ACCESS_KEY` from the environment, else the site's `.env`. On CaraCMS Cloud it
335
+ reads `CARACMS_CLOUD_KEY` instead (the same places) and uploads to the site's cloud
336
+ folder, with the same output; with both set it refuses.
335
337
  - The same image again writes nothing and prints its key with `"existed": true`. It never
336
338
  deletes anything.
337
339
  - JPEG, PNG, WebP, AVIF, HEIC/HEIF, GIF and SVG, up to 40 MB and 50 megapixels. If any file
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The settings fetch for a cloud site's media (caracms-cloud M3 Decision 20). The package
3
+ * has no boot hook in the built server, so **the first request that reaches the server, of
4
+ * any kind,** fetches `GET /v1/settings` (live/cloud.ts → `loadCloudSettings`) and every
5
+ * request waits on that one shared fetch before anything renders. The live runtime reads the
6
+ * same held answer, so it's one fetch per process. Prerendered pages are files and never wait.
7
+ *
8
+ * - Registered first (`addMiddleware({ order: 'pre' })` in index.ts), before the editor's.
9
+ * - Only for a cloud site's media: `CARACMS_CLOUD_KEY` set, `CARACMS_MEDIA` not `memory`,
10
+ * and none of the site's own media variables (that's `readMediaConfig`'s problem sentence).
11
+ * - A fetch that fails leaves the media unset (v3's handling: the editor's media routes 503
12
+ * with the cloud's sentence) and is tried again by a request after `RETRY_MS`, so a cloud
13
+ * that's away doesn't hold every request for its timeout.
14
+ *
15
+ * `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
16
+ * a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
17
+ * package's own. The build ignores `github.repo`: a site with no repo linked still builds.
18
+ * The key beside the site's own forms and email variables fails it too (caracms-cloud M4
19
+ * Decision 22), whether or not the media is the cloud's.
20
+ */
21
+ import type { MiddlewareHandler } from 'astro';
22
+ import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
23
+ import { cloudFormsConflict } from './live/mode.ts';
24
+ import { cloudMedia } from './media.ts';
25
+
26
+ type Env = Record<string, string | undefined>;
27
+
28
+ /** After a failed fetch, requests in the next half minute don't wait on another. */
29
+ export const RETRY_MS = 30_000;
30
+
31
+ const NEXT_TRY = Symbol.for('caracms.cloudSettingsNextTry');
32
+ const LOGGED = Symbol.for('caracms.cloudSettingsLogged');
33
+ type Shared = { [NEXT_TRY]?: number; [LOGGED]?: string };
34
+
35
+ /** Fetch the settings if this process doesn't hold them yet (and isn't waiting out a failure). */
36
+ export async function ensureCloudSettings(env: Env = process.env, now = Date.now(), log: (line: string) => void = (line) => console.error(line)): Promise<void> {
37
+ const cloud = cloudMedia(env);
38
+ if (!cloud || cloud.problems.length) return;
39
+ if (heldCloudSettings(cloud.connection)?.kind === 'ready') return;
40
+ const g = globalThis as Shared;
41
+ if ((g[NEXT_TRY] ?? 0) > now) return;
42
+ try {
43
+ await loadCloudSettings(cloud.connection);
44
+ delete g[NEXT_TRY];
45
+ delete g[LOGGED];
46
+ } catch (error) {
47
+ g[NEXT_TRY] = now + RETRY_MS;
48
+ const line = isCloudRefusal(error) ? `CaraCMS Cloud (${error.error}): ${error.message}` : 'CaraCMS Cloud: the settings could not be read';
49
+ if (g[LOGGED] !== line) log(`[caracms] the media settings could not be fetched: ${line}`);
50
+ g[LOGGED] = line;
51
+ }
52
+ }
53
+
54
+ /** Forget the retry clock (tests). */
55
+ export function resetCloudSettingsRetry(): void {
56
+ const g = globalThis as Shared;
57
+ delete g[NEXT_TRY];
58
+ delete g[LOGGED];
59
+ }
60
+
61
+ export const onRequest: MiddlewareHandler = async (_context, next) => {
62
+ await ensureCloudSettings();
63
+ return next();
64
+ };
65
+
66
+ /** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
67
+ export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
68
+ // caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
69
+ const conflict = cloudFormsConflict(env);
70
+ const forms = conflict ? [conflict] : [];
71
+ const cloud = cloudMedia(env);
72
+ if (!cloud) {
73
+ if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
74
+ return;
75
+ }
76
+ if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
77
+ let settings;
78
+ try {
79
+ settings = await loadCloudSettings(cloud.connection);
80
+ } catch (error) {
81
+ const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
82
+ throw new Error(`caracms: ${message}`);
83
+ }
84
+ if (!settings.media) throw new Error(`caracms: ${CLOUD_NO_MEDIA}`);
85
+ }
@@ -29,7 +29,8 @@ and an alt already used), `GET thumbnail?key=&width=` (a 302 to the signed URL),
29
29
  `POST video-upload`, `GET video-upload/<id>`, `GET clips`, `GET clips/<id>`, `GET playback/<id>`.
30
30
  Every shape is in `types.ts` → `MEDIA_ENDPOINTS`. `server/media/`: `sniff.ts` (the type and size
31
31
  from the bytes), `upload.ts` (the 40 MB cap, the key rule, no overwrite), `bucket.ts` (S3 through
32
- aws4fetch, and the in-memory bucket), `library.ts` (the 60 s listing, metadata per key,
32
+ aws4fetch, and the in-memory bucket), `cloud-bucket.ts` (the same seam on CaraCMS Cloud's media
33
+ API, `CARACMS_CLOUD_KEY`), `library.ts` (the 60 s listing, metadata per key,
33
34
  where-used), `mux.ts` (the template's Mux rules), `alt.ts` (`WaitingView.missingAlt`),
34
35
  `config.ts` (503s, images and clips apart) and `memory.ts` (`CARACMS_MEDIA=memory`: `memoryMedia()`
35
36
  to seed the bucket and the fake Mux in tests).
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The media bucket on CaraCMS Cloud (caracms-cloud M3 Decision 21), behind bucket.ts's
3
+ * seam, so the upload route (checks, key, dedupe, metadata) and the files route don't
4
+ * change. The site's server never holds R2 credentials: it asks the API (live/cloud.ts,
5
+ * the site key) and the API answers for the site's own folder. Keys stay `uploads/…`:
6
+ * the cloud adds `sites/<folder>/` and strips it again.
7
+ *
8
+ * - `list` and `first`: `GET /v1/media/list`, following its `next` tokens (`list`) or one page (`first`).
9
+ * - `head`: `GET /v1/media/object`; `404 not_found` is "no such object".
10
+ * - `get`: `POST /v1/media/download-url`, then the presigned GET, streamed (the files route).
11
+ * - `put`: `POST /v1/media/upload-url`, then the presigned PUT with exactly the headers it names.
12
+ *
13
+ * **The cloud's refusals are shown as sent** (`CloudRefusal`: `upload_limit`'s sentence,
14
+ * `storage_unavailable`'s); the media handler puts the message in its 502. A cloud that
15
+ * can't be reached, and the storage's own answers to the presigned URLs, are BucketErrors
16
+ * (the handler's own sentences). Presigned URLs go nowhere but the fetch here: never in
17
+ * a log line, an error or an answer to the browser.
18
+ */
19
+ import { isCloudRefusal, cloudCall, type CloudConnection } from '../../../live/cloud.ts';
20
+ import { BucketError, type Bucket, type BucketObject, type ObjectMeta } from './bucket.ts';
21
+
22
+ const STORAGE_TIMEOUT_MS = 20_000;
23
+ /** A runaway-token guard, as s3Bucket's: 1000 pages of 1000 is far past one site's library. */
24
+ const MAX_PAGES = 1000;
25
+
26
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null);
27
+ const text = (v: unknown) => (typeof v === 'string' && v ? v : null);
28
+ const iso = (v: unknown) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? new Date(v).toISOString() : null);
29
+
30
+ export function cloudBucket(connection: Pick<CloudConnection, 'key' | 'url'>, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args)): Bucket {
31
+ const conn: CloudConnection = { ...connection, fetch: fetchImpl };
32
+
33
+ /** One API call; a cloud that can't be reached is a BucketError, any other refusal goes up as sent. */
34
+ async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
35
+ try {
36
+ return await cloudCall(conn, method, path, body);
37
+ } catch (error) {
38
+ if (isCloudRefusal(error) && error.error === 'unreachable') throw new BucketError(`${method} ${path.split('?')[0]}: CaraCMS Cloud unreachable`);
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ /** A presigned URL's request. The timeout covers the wait for the storage's headers (a `get` streams after). */
44
+ async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(new Error(`no answer in ${STORAGE_TIMEOUT_MS / 1000} s`)), STORAGE_TIMEOUT_MS);
47
+ try {
48
+ return await fetchImpl(url, { ...init, signal: controller.signal });
49
+ } catch (error) {
50
+ throw new BucketError(`${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
51
+ } finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+
56
+ async function page(prefix: string, after: string | null): Promise<{ objects: BucketObject[]; next: string | null }> {
57
+ const query = new URLSearchParams({ prefix });
58
+ if (after) query.set('after', after);
59
+ const answer = await api('GET', `/v1/media/list?${query}`);
60
+ if (!Array.isArray(answer.objects)) throw new BucketError(`list ${prefix}: the answer has no objects`);
61
+ const objects: BucketObject[] = [];
62
+ for (const o of answer.objects as Record<string, unknown>[]) {
63
+ const key = o && typeof o === 'object' ? text(o.key) : null;
64
+ if (!key) continue;
65
+ objects.push({ key, lastModified: iso(o.lastModified), size: typeof o.size === 'number' && Number.isFinite(o.size) ? o.size : null });
66
+ }
67
+ return { objects, next: text(answer.next) };
68
+ }
69
+
70
+ return {
71
+ async list(prefix) {
72
+ const out: BucketObject[] = [];
73
+ const seen = new Set<string>();
74
+ let after: string | null = null;
75
+ for (let n = 0; n < MAX_PAGES; n++) {
76
+ const { objects, next } = await page(prefix, after);
77
+ out.push(...objects);
78
+ if (!next || seen.has(next)) break;
79
+ seen.add(next);
80
+ after = next;
81
+ }
82
+ return out;
83
+ },
84
+ async first(prefix) {
85
+ const { objects } = await page(prefix, null);
86
+ return objects[0]?.key ?? null;
87
+ },
88
+ async head(key): Promise<ObjectMeta | null> {
89
+ let answer: Record<string, unknown>;
90
+ try {
91
+ answer = await api('GET', `/v1/media/object?${new URLSearchParams({ key })}`);
92
+ } catch (error) {
93
+ if (isCloudRefusal(error) && error.error === 'not_found') return null;
94
+ throw error;
95
+ }
96
+ return {
97
+ w: num(answer.w),
98
+ h: num(answer.h),
99
+ name: text(answer.name),
100
+ by: text(answer.by),
101
+ contentType: text(answer.contentType),
102
+ lastModified: iso(answer.lastModified),
103
+ size: typeof answer.size === 'number' && Number.isFinite(answer.size) ? answer.size : null,
104
+ };
105
+ },
106
+ async get(key) {
107
+ const { url } = await api('POST', '/v1/media/download-url', { key });
108
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url)) throw new BucketError(`get ${key}: the answer has no URL`);
109
+ const res = await storage(`get ${key}`, url, { method: 'GET' });
110
+ if (res.status === 404) {
111
+ await res.body?.cancel().catch(() => {});
112
+ return null;
113
+ }
114
+ if (!res.ok || !res.body) {
115
+ await res.body?.cancel().catch(() => {});
116
+ throw new BucketError(`get ${key}: ${res.status}`, res.status);
117
+ }
118
+ const size = Number(res.headers.get('content-length'));
119
+ return { body: res.body, size: res.headers.has('content-length') && Number.isFinite(size) ? size : null };
120
+ },
121
+ async put({ key, body, contentType, sha256, meta }) {
122
+ const answer = await api('POST', '/v1/media/upload-url', {
123
+ key,
124
+ size: body.byteLength,
125
+ contentType,
126
+ sha256,
127
+ meta: { ...(meta.w !== undefined ? { w: meta.w } : {}), ...(meta.h !== undefined ? { h: meta.h } : {}), name: meta.name, by: meta.by },
128
+ });
129
+ const { url, method, headers } = answer;
130
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
131
+ throw new BucketError(`put ${key}: the upload answer isn't one`);
132
+ }
133
+ // The headers as the cloud named them, verbatim (the URL signs each one). Content-Length and Host
134
+ // are fetch's own to send: the body is exactly the declared size, and the URL's host is the signed one.
135
+ const sent = Object.fromEntries(
136
+ Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
137
+ ) as Record<string, string>;
138
+ const res = await storage(`put ${key}`, url, { method: 'PUT', headers: sent, body: body as BodyInit });
139
+ await res.body?.cancel().catch(() => {});
140
+ // 412: R2 refused the signed If-None-Match: * because the key is there, and the key carries the bytes' hash: already stored.
141
+ if (!res.ok && res.status !== 412) throw new BucketError(`put ${key}: ${res.status}`, res.status);
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Mux on CaraCMS Cloud (caracms-cloud M4.5 Decision 16), behind mux.ts's seam, so the clip
3
+ * routes, where-used (`withUses`) and the editor don't change. The site's server holds no
4
+ * Mux token: it asks the API (live/cloud.ts, the site key), which asks the cloud's own
5
+ * Mux environment and answers from the site's own clips only. One route per method:
6
+ *
7
+ * - `createUpload`: `POST /v1/video/upload` `{ origin, title }` → `{ id, url }`.
8
+ * - `upload`: `GET /v1/video/upload?id=` → `VideoUploadStatus`.
9
+ * - `clip`: `GET /v1/video/clip?id=` → `Clip`; `clipOfPlayback`: `GET /v1/video/playback?id=`.
10
+ * - `clips`: `GET /v1/video/clips` → `{ clips }`, newest first (the cloud keeps the order).
11
+ *
12
+ * **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
13
+ * them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
14
+ * with the cloud's sentence as sent; any other refusal (`video_unavailable`, the key's own
15
+ * refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
16
+ * an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
17
+ * logged with the refusal's code. The key is never logged nor put in an error.
18
+ */
19
+ import type { VideoUploadStatus } from '../../types.ts';
20
+ import { cloudCall, isCloudRefusal, type CloudConnection } from '../../../live/cloud.ts';
21
+ import { MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
22
+
23
+ export const CLOUD_VIDEO_UNREACHABLE = "CaraCMS Cloud couldn't be reached just now. Try again in a minute.";
24
+ export const CLOUD_VIDEO_UNREADABLE = 'CaraCMS Cloud sent an unreadable reply.';
25
+
26
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
27
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
28
+ const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
29
+
30
+ export function cloudMux(
31
+ connection: Pick<CloudConnection, 'key' | 'url'>,
32
+ options: { fetch?: typeof fetch; log?: (line: string) => void } = {},
33
+ ): MuxClient {
34
+ const conn: CloudConnection = { ...connection, fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)) };
35
+ const log = options.log ?? ((line: string) => console.error(line));
36
+
37
+ async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
38
+ const where = `${method} ${path.split('?')[0]}`;
39
+ try {
40
+ return await cloudCall(conn, method, path, body);
41
+ } catch (error) {
42
+ if (!isCloudRefusal(error)) throw error;
43
+ if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
44
+ if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
45
+ log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
46
+ throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
47
+ }
48
+ }
49
+
50
+ const unreadable = (where: string): never => {
51
+ log(`[caracms] video ${where}: CaraCMS Cloud sent an unreadable reply`);
52
+ throw new MuxRouteError(502, CLOUD_VIDEO_UNREADABLE);
53
+ };
54
+
55
+ /** The cloud's `Clip`, checked field by field (it passes `toClip`'s shape through). */
56
+ const clipOf = (raw: unknown, where: string): Clip => {
57
+ const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
58
+ const id = str(c?.id);
59
+ if (!c || !id) return unreadable(where);
60
+ const playbackId = str(c.playbackId);
61
+ const errored = c.errored === true;
62
+ return {
63
+ id,
64
+ playbackId: playbackId && MUX_ID.test(playbackId) ? playbackId : null,
65
+ status: str(c.status) ?? 'unknown',
66
+ ready: c.ready === true,
67
+ errored,
68
+ error: errored ? (str(c.error) ?? 'Mux could not process this file.') : null,
69
+ title: str(c.title),
70
+ duration: num(c.duration),
71
+ aspectRatio: str(c.aspectRatio),
72
+ w: px(c.w),
73
+ h: px(c.h),
74
+ createdAt: str(c.createdAt),
75
+ };
76
+ };
77
+
78
+ const query = (id: string) => new URLSearchParams({ id }).toString();
79
+
80
+ return {
81
+ async createUpload(origin, title) {
82
+ const answer = await api('POST', '/v1/video/upload', { origin, title });
83
+ const id = str(answer.id);
84
+ const url = str(answer.url);
85
+ if (!id || !url || !/^https:\/\//.test(url)) return unreadable('POST /v1/video/upload');
86
+ return { id, url };
87
+ },
88
+ async upload(id): Promise<VideoUploadStatus> {
89
+ const answer = await api('GET', `/v1/video/upload?${query(id)}`);
90
+ const assetId = str(answer.assetId);
91
+ return {
92
+ id: str(answer.id) ?? id,
93
+ status: str(answer.status) ?? 'unknown',
94
+ assetId: assetId && MUX_ID.test(assetId) ? assetId : null,
95
+ error: str(answer.error),
96
+ };
97
+ },
98
+ async clip(assetId) {
99
+ return clipOf(await api('GET', `/v1/video/clip?${query(assetId)}`), 'GET /v1/video/clip');
100
+ },
101
+ async clipOfPlayback(playbackId) {
102
+ return clipOf(await api('GET', `/v1/video/playback?${query(playbackId)}`), 'GET /v1/video/playback');
103
+ },
104
+ async clips() {
105
+ const answer = await api('GET', '/v1/video/clips');
106
+ if (!Array.isArray(answer.clips)) return unreadable('GET /v1/video/clips');
107
+ return answer.clips.map((c) => clipOf(c, 'GET /v1/video/clips'));
108
+ },
109
+ };
110
+ }
@@ -10,8 +10,16 @@
10
10
  * - Images need imgproxy's (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`, `IMGPROXY_SALT`, which
11
11
  * sign the thumbnails) and the bucket's (`S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`,
12
12
  * `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
13
+ * - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
14
+ * one credential: the bucket is the cloud's (cloud-bucket.ts), the signing from the
15
+ * settings the process holds (../../../media.ts).
16
+ * - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
17
+ * cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
18
+ * beside it is a problem sentence naming them (one credential, not two).
13
19
  */
14
- import { isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
20
+ import type { CloudConnection } from '../../../live/cloud.ts';
21
+ import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
22
+ import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
15
23
 
16
24
  type Env = Record<string, string | undefined>;
17
25
 
@@ -26,9 +34,14 @@ export interface BucketSettings {
26
34
  export type ImagesConfig =
27
35
  | { kind: 'memory'; signing: MediaSettings }
28
36
  | { kind: 's3'; signing: MediaSettings; bucket: BucketSettings }
37
+ | { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
29
38
  | { kind: 'unset'; message: string; missing: string[] };
30
39
 
31
- export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
40
+ export type MuxConfig =
41
+ | { kind: 'memory' }
42
+ | { kind: 'mux'; tokenId: string; tokenSecret: string }
43
+ | { kind: 'cloud'; connection: Pick<CloudConnection, 'key' | 'url'> }
44
+ | { kind: 'unset'; message: string; missing: string[] };
32
45
 
33
46
  export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
34
47
 
@@ -48,6 +61,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
48
61
  return { kind: 'memory', signing: signing.settings };
49
62
  }
50
63
  const signing = readMediaConfig(env, production);
64
+ const cloud = cloudMedia(env);
65
+ if (cloud) {
66
+ if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
67
+ return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
68
+ }
51
69
  const missing = signing.kind === 'unset' ? [...signing.missing] : [];
52
70
  const problems = signing.kind === 'unset' ? [...signing.problems] : [];
53
71
  const need = (name: string) => {
@@ -67,9 +85,18 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
67
85
  return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
68
86
  }
69
87
 
70
- /** The clip routes' settings: the site's Mux environment's token. */
88
+ /** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
71
89
  export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
72
90
  if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
91
+ const key = get(env, 'CARACMS_CLOUD_KEY');
92
+ if (key) {
93
+ const problems: string[] = [];
94
+ const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
95
+ if (both.length) problems.push(CLOUD_AND_MUX_PROBLEM(both));
96
+ const url = readCloudUrl(get(env, 'CARACMS_CLOUD_URL'), problems);
97
+ if (problems.length) return { kind: 'unset', message: problems.join(' '), missing: [] };
98
+ return { kind: 'cloud', connection: { key, url } };
99
+ }
73
100
  const tokenId = get(env, 'MUX_TOKEN_ID');
74
101
  const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
75
102
  const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
@@ -10,7 +10,8 @@
10
10
  * - **No lock** (Decision 7): an upload, or reading the library, changes no document.
11
11
  * - **Every reply is `Cache-Control: private, no-store`.**
12
12
  * - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
13
- * imgproxy) and clips (Mux) apart: one missing never disables the other.
13
+ * imgproxy) and clips (Mux, or CaraCMS Cloud's: cloud-mux.ts) apart: one missing never
14
+ * disables the other.
14
15
  * - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
15
16
  * only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
16
17
  * - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
@@ -41,6 +42,9 @@ import type { DraftsStore } from '../drafts-store.ts';
41
42
  import { parseReplaceBody } from '../replace.ts';
42
43
  import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
43
44
  import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
45
+ import { cloudBucket } from './cloud-bucket.ts';
46
+ import { cloudMux } from './cloud-mux.ts';
47
+ import { isCloudRefusal } from '../../../live/cloud.ts';
44
48
  import { readImagesConfig, readMuxConfig } from './config.ts';
45
49
  import { isFileKey } from '../../../files.ts';
46
50
  import { entryOf, fileEntryOf, libraryFor, mediaUses, UPLOADS, type Library, type MediaUses } from './library.ts';
@@ -76,6 +80,7 @@ function images(ctx: MediaContext): { library: Library; signing: MediaSettings }
76
80
  const config = readImagesConfig(ctx.env ?? process.env, ctx.production);
77
81
  if (config.kind === 'unset') return json(503, { error: config.message, code: 'media-unset', missing: config.missing } satisfies MediaUnset);
78
82
  if (config.kind === 'memory') return { library: libraryFor('memory', () => memoryMedia().bucket), signing: config.signing };
83
+ if (config.kind === 'cloud') return { library: libraryFor(`cloud:${config.connection.url}:${config.signing.prefix}`, (): Bucket => cloudBucket(config.connection, ctx.fetch)), signing: config.signing };
79
84
  const b = config.bucket;
80
85
  return { library: libraryFor(`s3:${b.endpoint}:${b.bucket}:${b.accessKeyId}`, (): Bucket => s3Bucket(b, ctx.fetch)), signing: config.signing };
81
86
  }
@@ -85,6 +90,8 @@ function mux(ctx: MediaContext): MuxClient | Response {
85
90
  if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
86
91
  const log = ctx.log ?? ((line: string) => console.error(line));
87
92
  if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
93
+ // caracms-cloud M4.5: the cloud's Mux, through the site key; its refusals arrive as MuxRouteErrors.
94
+ if (config.kind === 'cloud') return cloudMux(config.connection, { fetch: ctx.fetch, log });
88
95
  return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
89
96
  }
90
97
 
@@ -210,6 +217,11 @@ export async function handleMedia(request: Request, ctx: MediaContext): Promise<
210
217
  if (error instanceof UploadRefused) return json(error.status, { error: error.message, code: error.code } satisfies MediaRefusal);
211
218
  if (error instanceof MuxRouteError) return json(error.status, { error: error.message } satisfies ApiError);
212
219
  if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
220
+ if (isCloudRefusal(error)) {
221
+ // caracms-cloud M3: the cloud's own sentence, as sent (a full site, its storage away).
222
+ log(`[caracms] media bucket: CaraCMS Cloud (${error.error}): ${error.message}`);
223
+ return json(502, { error: error.message } satisfies ApiError);
224
+ }
213
225
  if (error instanceof BucketError) {
214
226
  log(`[caracms] media bucket: ${error.message}`);
215
227
  const said = error.status !== null && error.status >= 400 && error.status < 500;
@@ -32,6 +32,7 @@ import type { APIRoute } from 'astro';
32
32
  import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
33
33
  import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
34
34
  import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
35
+ import { cloudBucket } from './editor/server/media/cloud-bucket.ts';
35
36
  import { readImagesConfig } from './editor/server/media/config.ts';
36
37
  import { memoryMedia } from './editor/server/media/memory.ts';
37
38
  import { isProductionBuild } from './media.ts';
@@ -168,7 +169,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
168
169
  }
169
170
 
170
171
  // 6. The one key the allow-list passed.
171
- const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : s3Bucket(config.bucket, ctx.fetch);
172
+ const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : config.kind === 'cloud' ? cloudBucket(config.connection, ctx.fetch) : s3Bucket(config.bucket, ctx.fetch);
172
173
  let object;
173
174
  try {
174
175
  object = await bucket.get(file.key);