caracms 0.5.0 → 0.6.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/src/live/cloud.ts CHANGED
@@ -3,8 +3,14 @@
3
3
  *
4
4
  * - `CARACMS_CLOUD_KEY` (the site key, `cck_…`) is sent as `Authorization: Bearer` and
5
5
  * nowhere else; `CARACMS_CLOUD_URL` overrides the production address below.
6
- * - `cloudSettings()`: `GET /v1/settings` at start, for the site's repo (`github.repo`;
7
- * null is `no_repo`, with the cloud's own sentence).
6
+ * - `fetchCloudSettings()`: `GET /v1/settings`, the whole answer the package uses: the
7
+ * site's repo, its folder and its media (bucket, image host, imgproxy pair; M3).
8
+ * `loadCloudSettings()` is the one fetch per process (M3 Decision 20): the build's start,
9
+ * or the first request a server gets, and the live runtime, all share it, held on
10
+ * `globalThis` (the build renders pages through another module instance than its hooks);
11
+ * `heldCloudSettings()` reads that copy synchronously (`readMediaConfig`).
12
+ * - `cloudSettings()`: the site's repo from those settings (`github.repo`; null is
13
+ * `no_repo`, with the cloud's own sentence). Only the editor needs a repo.
8
14
  * - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
9
15
  * about an hour long. `GitHubClient` calls it and refreshes five minutes early.
10
16
  * - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
@@ -15,7 +21,7 @@
15
21
  * - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
16
22
  * the sentence escaped and the install link a link.
17
23
  *
18
- * The key and the tokens are never logged and never put in an error.
24
+ * The key, the tokens and the imgproxy pair are never logged and never put in an error.
19
25
  */
20
26
 
21
27
  /** The production API's address (caracms-cloud M0 → Contracts). The one place it's written. */
@@ -25,6 +31,8 @@ export const DEFAULT_CLOUD_URL = 'https://glad-bird-412.ap-southeast-2.convex.si
25
31
  export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
26
32
 
27
33
  export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
34
+ /** M3 Decision 20: the build's own sentence for a cloud it can't reach. */
35
+ export const CLOUD_UNREACHABLE_BUILD = "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute.";
28
36
  /** The cloud's own `no_repo` sentence (Contracts), for `github.repo: null` in the settings. */
29
37
  export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one.';
30
38
  /** Decision 13: a token answer named another repo than the one the server started with. */
@@ -41,8 +49,23 @@ export interface CloudConnection {
41
49
  timeoutMs?: number;
42
50
  }
43
51
 
52
+ /**
53
+ * Marks a `CloudRefusal`. `Symbol.for` is shared across module instances, so a refusal
54
+ * thrown by a second copy of this file (Vite's dev SSR graph can hold two) is still known.
55
+ */
56
+ const REFUSAL_BRAND = Symbol.for('caracms.CloudRefusal');
57
+
58
+ /**
59
+ * Is this a `CloudRefusal`, from any instance of this module? Use this, never
60
+ * `instanceof CloudRefusal`: that fails when the refusal came from the other copy.
61
+ */
62
+ export function isCloudRefusal(value: unknown): value is CloudRefusal {
63
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[REFUSAL_BRAND] === true;
64
+ }
65
+
44
66
  /** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
45
67
  export class CloudRefusal extends Error {
68
+ readonly [REFUSAL_BRAND] = true;
46
69
  /** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed`. */
47
70
  readonly error: string;
48
71
  /** An `https://` link the message names (the App's install page), or null. */
@@ -57,45 +80,155 @@ export class CloudRefusal extends Error {
57
80
 
58
81
  const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
59
82
 
60
- async function call(connection: CloudConnection, method: 'GET' | 'POST', path: string): Promise<Record<string, unknown>> {
83
+ /**
84
+ * One call to the API: `path` with its query, a JSON `body` for a POST. The answer's JSON
85
+ * object, or a `CloudRefusal` (the cloud's own, or `unreachable`). The media bucket
86
+ * (editor/server/media/cloud-bucket.ts) calls it too.
87
+ */
88
+ export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
61
89
  let res: Response;
62
- let body: unknown;
90
+ let answer: unknown;
63
91
  try {
64
92
  res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
65
93
  method,
66
- headers: { authorization: `Bearer ${connection.key}`, accept: 'application/json', 'user-agent': 'caracms' },
94
+ headers: {
95
+ authorization: `Bearer ${connection.key}`,
96
+ accept: 'application/json',
97
+ 'user-agent': 'caracms',
98
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
99
+ },
100
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
67
101
  signal: AbortSignal.timeout(connection.timeoutMs ?? CLOUD_TIMEOUT_MS),
68
102
  });
69
- body = await res.json();
103
+ answer = await res.json();
70
104
  } catch {
71
105
  throw unreachable();
72
106
  }
73
- const answer = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
74
- if (!answer) throw unreachable();
107
+ const object = answer && typeof answer === 'object' && !Array.isArray(answer) ? (answer as Record<string, unknown>) : null;
108
+ if (!object) throw unreachable();
75
109
  if (!res.ok) {
76
- const { error, message, installUrl } = answer;
110
+ const { error, message, installUrl } = object;
77
111
  if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
78
112
  throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
79
113
  }
80
- return answer;
114
+ return object;
81
115
  }
82
116
 
83
117
  const REPO = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
84
118
 
85
- /** The site's repo, from `GET /v1/settings`. */
86
- export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
87
- const answer = await call(connection, 'GET', '/v1/settings');
119
+ /** What the package uses of `GET /v1/settings` (caracms-cloud M1's shape; M3 adds nothing it needs). */
120
+ export interface CloudSettings {
121
+ /** The linked repo, or null (`no_repo`, which only the editor minds: a build ignores it). */
122
+ repo: { owner: string; repo: string } | null;
123
+ /** The site's media, or null when the answer lacks any of it (the editor can still start; images can't be built). */
124
+ media: {
125
+ /** The site's folder in the media bucket, `sites/<folder>/`: the prefix the cloud adds to every key. */
126
+ folder: string;
127
+ bucket: string;
128
+ /** Bare, `img.caracms.cloud`. */
129
+ imageHost: string;
130
+ /** imgproxy's pair for this site, hex. Never logged, never sent to a browser. */
131
+ key: string;
132
+ salt: string;
133
+ } | null;
134
+ }
135
+
136
+ /** The package's sentence for settings with no usable media (a cloud set up without its bucket or image host). */
137
+ export const CLOUD_NO_MEDIA = "CaraCMS Cloud's settings for this site have no media yet, so images can't be built. Ask CaraCMS to check the site.";
138
+
139
+ const FOLDER = /^sites\/[a-z0-9][a-z0-9-]*\/$/;
140
+ const HOST = /^[A-Za-z0-9.-]+(?::\d+)?$/;
141
+ const HEX = /^(?:[0-9a-fA-F]{2})+$/;
142
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
143
+
144
+ /** `GET /v1/settings`, checked. No `github` or a malformed repo counts as unreachable; incomplete media is `media: null`. */
145
+ export async function fetchCloudSettings(connection: CloudConnection): Promise<CloudSettings> {
146
+ const answer = await cloudCall(connection, 'GET', '/v1/settings');
88
147
  const github = answer.github as { repo?: unknown } | undefined;
148
+ const media = answer.media as { bucket?: unknown; imageHost?: unknown } | undefined;
149
+ const pair = answer.imgproxy as { key?: unknown; salt?: unknown } | undefined;
89
150
  if (!github || typeof github !== 'object') throw unreachable();
90
- if (github.repo === null) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
91
151
  const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
92
- if (!m) throw unreachable();
93
- return { owner: m[1]!, repo: m[2]! };
152
+ if (github.repo !== null && !m) throw unreachable();
153
+ const folder = str(answer.folder);
154
+ const bucket = str(media && typeof media === 'object' ? media.bucket : null);
155
+ const imageHost = str(media && typeof media === 'object' ? media.imageHost : null);
156
+ const key = str(pair && typeof pair === 'object' ? pair.key : null);
157
+ const salt = str(pair && typeof pair === 'object' ? pair.salt : null);
158
+ const ok = folder && FOLDER.test(folder) && bucket && imageHost && HOST.test(imageHost) && key && HEX.test(key) && salt && HEX.test(salt);
159
+ return { repo: m ? { owner: m[1]!, repo: m[2]! } : null, media: ok ? { folder, bucket, imageHost, key, salt } : null };
160
+ }
161
+
162
+ /** The site's repo (the editor's start): `no_repo` when none is linked. */
163
+ export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
164
+ return repoOf(await fetchCloudSettings(connection));
165
+ }
166
+
167
+ export function repoOf(settings: CloudSettings): { owner: string; repo: string } {
168
+ if (!settings.repo) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
169
+ return settings.repo;
170
+ }
171
+
172
+ // ── The one fetch per process (M3 Decision 20) ─────────────────────────────────
173
+
174
+ const HELD = Symbol.for('caracms.cloudSettings');
175
+ interface Held {
176
+ key: string;
177
+ url: string;
178
+ pending: Promise<CloudSettings> | null;
179
+ value: CloudSettings | null;
180
+ /** The last fetch's refusal, while no fetch has succeeded: the media-unset sentence. */
181
+ failure: CloudRefusal | null;
182
+ }
183
+ const held = () => globalThis as { [HELD]?: Held };
184
+
185
+ /**
186
+ * The settings, fetched once per process and shared: the build's start, a server's first
187
+ * request and the live runtime all come here, so there's one `GET /v1/settings`. A fetch
188
+ * that fails is forgotten (its refusal kept for `heldCloudSettings`), so the next caller
189
+ * tries again. Another key or address than the held one fetches afresh (tests).
190
+ */
191
+ export function loadCloudSettings(connection: CloudConnection): Promise<CloudSettings> {
192
+ const g = held();
193
+ const h = g[HELD];
194
+ if (h && h.key === connection.key && h.url === connection.url) {
195
+ if (h.value) return Promise.resolve(h.value);
196
+ if (h.pending) return h.pending;
197
+ }
198
+ const next: Held = { key: connection.key, url: connection.url, pending: null, value: null, failure: h?.key === connection.key && h.url === connection.url ? h.failure : null };
199
+ next.pending = fetchCloudSettings(connection).then(
200
+ (value) => {
201
+ next.value = value;
202
+ next.failure = null;
203
+ next.pending = null;
204
+ return value;
205
+ },
206
+ (error: unknown) => {
207
+ next.pending = null;
208
+ next.failure = isCloudRefusal(error) ? error : unreachable();
209
+ throw next.failure;
210
+ },
211
+ );
212
+ g[HELD] = next;
213
+ return next.pending;
214
+ }
215
+
216
+ /** The held copy for this key and address: ready, the last fetch's refusal, or null (not fetched yet, or fetching). */
217
+ export function heldCloudSettings(connection: Pick<CloudConnection, 'key' | 'url'>): { kind: 'ready'; settings: CloudSettings } | { kind: 'failed'; refusal: CloudRefusal } | null {
218
+ const h = held()[HELD];
219
+ if (!h || h.key !== connection.key || h.url !== connection.url) return null;
220
+ if (h.value) return { kind: 'ready', settings: h.value };
221
+ return h.failure ? { kind: 'failed', refusal: h.failure } : null;
222
+ }
223
+
224
+ /** Forget the held settings (tests). */
225
+ export function resetCloudSettings(): void {
226
+ delete held()[HELD];
94
227
  }
95
228
 
96
229
  /** A fresh installation token on the site's repo, from `POST /v1/github/token`. */
97
230
  export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
98
- const answer = await call(connection, 'POST', '/v1/github/token');
231
+ const answer = await cloudCall(connection, 'POST', '/v1/github/token');
99
232
  const { token, expiresAt, repo } = answer;
100
233
  const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
101
234
  if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
@@ -31,7 +31,7 @@
31
31
  * The private key, the site key and the tokens are never logged.
32
32
  */
33
33
  import { createSign } from 'node:crypto';
34
- import { CLOUD_REPO_CHANGED, CloudRefusal, cloudToken } from './cloud.ts';
34
+ import { CLOUD_REPO_CHANGED, CloudRefusal, isCloudRefusal, cloudToken } from './cloud.ts';
35
35
 
36
36
  const API = 'https://api.github.com';
37
37
 
@@ -341,7 +341,7 @@ export class GitHubClient {
341
341
  try {
342
342
  answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs });
343
343
  } catch (error) {
344
- if (error instanceof CloudRefusal && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
344
+ if (isCloudRefusal(error) && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
345
345
  throw error;
346
346
  }
347
347
  if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
package/src/live/mode.ts CHANGED
@@ -97,6 +97,10 @@ export const CLOUD_KEY_PROBLEM =
97
97
  "CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
98
98
  export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
99
99
  `Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
100
+ /** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
101
+ export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
102
+ const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
103
+ export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
100
104
  export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
101
105
  export const CLOUD_URL_PROBLEM = (url: string) =>
102
106
  `CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
@@ -187,6 +191,9 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
187
191
  if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
188
192
  if (get('GITHUB_APP_ID')) problems.push(CLOUD_AND_GITHUB_PROBLEM('GITHUB_APP_ID'));
189
193
  if (get('GITHUB_REPO')) problems.push(CLOUD_REPO_PROBLEM);
194
+ // M3 Decision 19: the key is the media credential too (unless the tests' in-memory media is on).
195
+ const ownMedia = OWN_MEDIA_VARIABLES.filter((name) => get(name));
196
+ if (ownMedia.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MEDIA_PROBLEM(ownMedia));
190
197
  github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
191
198
  } else if (!memory || production) {
192
199
  const appId = get('GITHUB_APP_ID');
@@ -298,7 +305,7 @@ function isBranchName(name: string): boolean {
298
305
  }
299
306
 
300
307
  /** CARACMS_CLOUD_URL, without a trailing slash: https://, or http:// on localhost. Unset, the production address. */
301
- function readCloudUrl(raw: string | null, problems: string[]): string {
308
+ export function readCloudUrl(raw: string | null, problems: string[]): string {
302
309
  if (!raw) return DEFAULT_CLOUD_URL;
303
310
  let url: URL | null = null;
304
311
  try {
@@ -20,7 +20,7 @@ import { join, relative, sep } from 'node:path';
20
20
  import { repoPrefix, type ResolvedSite } from '../config.ts';
21
21
  import { GitHubBackend, MemoryBackend, type FileWrite, type GitBackend } from './backend.ts';
22
22
  import { DEFAULT_BRANCHES } from './branches.ts';
23
- import { cloudSettings } from './cloud.ts';
23
+ import { fetchCloudSettings, heldCloudSettings, loadCloudSettings, repoOf, type CloudConnection } from './cloud.ts';
24
24
  import { GitHubClient, Tracker } from './github.ts';
25
25
  import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
26
26
  import type { LiveSettings } from './mode.ts';
@@ -57,6 +57,19 @@ export interface RuntimeOptions {
57
57
  fetch?: typeof fetch;
58
58
  }
59
59
 
60
+ /**
61
+ * The repo for the editor's start. M3 Decision 20: the settings the process already holds (the
62
+ * first request fetched them for the media), so one GET /v1/settings, not two; a test's own fetch
63
+ * asks afresh. A held copy with no repo isn't reused: the repo may have been linked since, so
64
+ * this start asks again (one more GET, only while the repo is missing). The held copy is left
65
+ * as it is, so the media keep working off it.
66
+ */
67
+ export async function cloudRepoForStart(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
68
+ const held = heldCloudSettings(connection);
69
+ const stale = held?.kind === 'ready' && !held.settings.repo;
70
+ return repoOf(await (connection.fetch || stale ? fetchCloudSettings(connection) : loadCloudSettings(connection)));
71
+ }
72
+
60
73
  /** Build and start a runtime (not shared: tests make their own). */
61
74
  export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRuntime> {
62
75
  const { site, settings } = options;
@@ -80,7 +93,7 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
80
93
  if (gh.kind === 'cloud') {
81
94
  // caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
82
95
  // token, so a refusal of either stops the start (the sign-in guard shows its sentence).
83
- const { owner, repo } = await cloudSettings({ key: gh.key, url: gh.url, fetch });
96
+ const { owner, repo } = await cloudRepoForStart({ key: gh.key, url: gh.url, fetch });
84
97
  client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
85
98
  await client.ready();
86
99
  log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
package/src/media.ts CHANGED
@@ -22,9 +22,19 @@
22
22
  * `IMGPROXY_KEY` and `IMGPROXY_SALT` (hex, the values imgproxy has), `S3_BUCKET` (the
23
23
  * bucket in the `s3://` source). `CARACMS_MEDIA=memory` supplies `https://img.test`, a
24
24
  * fixed test key and salt and a test bucket, and is refused on a production server.
25
+ *
26
+ * **On CaraCMS Cloud** (caracms-cloud M3 Decisions 19–20) `CARACMS_CLOUD_KEY` replaces all
27
+ * of them: the origin (`https://<media.imageHost>`), the pair, the bucket and the site's
28
+ * folder come from the cloud's settings, fetched once per process (live/cloud.ts →
29
+ * `loadCloudSettings`, at the build's start and by a server's first request) and read here
30
+ * from that held copy. The folder goes before the key in the source,
31
+ * `s3://<bucket>/sites/<folder>/<key>`; content keeps `uploads/…`. The key with any of the
32
+ * site's own media variables is a problem sentence (one credential, not two).
25
33
  */
26
34
  import { createHmac } from 'node:crypto';
27
35
  import type { ImageFormat } from './fields.ts';
36
+ import { CLOUD_NO_MEDIA, heldCloudSettings, type CloudConnection } from './live/cloud.ts';
37
+ import { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES, readCloudUrl } from './live/mode.ts';
28
38
  import { renderMedia, type RenderImage } from './media-value.ts';
29
39
 
30
40
  /** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
@@ -52,6 +62,8 @@ export interface MediaSettings {
52
62
  key: string;
53
63
  salt: string;
54
64
  bucket: string;
65
+ /** Before every key in the source: the site's folder on CaraCMS Cloud (`sites/<folder>/`), else none. */
66
+ prefix?: string;
55
67
  memory: boolean;
56
68
  }
57
69
 
@@ -70,6 +82,40 @@ export function isProductionBuild(): boolean {
70
82
  type Env = Record<string, string | undefined>;
71
83
  const HEX = /^(?:[0-9a-fA-F]{2})+$/;
72
84
 
85
+ export { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES };
86
+ /** No settings held yet: nothing has fetched them in this process (the middleware and the build's start do). */
87
+ export const CLOUD_MEDIA_NOT_FETCHED = "The site's media settings haven't come from CaraCMS Cloud yet.";
88
+ /** A held fetch that couldn't reach the cloud, in the media's words (the cloud's own refusals are shown as sent). */
89
+ export const CLOUD_MEDIA_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so images can't be shown. Try again in a minute.";
90
+
91
+ /**
92
+ * The cloud as the media credential: the connection when `CARACMS_CLOUD_KEY` is set and
93
+ * `CARACMS_MEDIA` isn't `memory` (which wins over everything), with any problems (the
94
+ * site's own media variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site's media.
95
+ */
96
+ export function cloudMedia(env: Env = process.env): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
97
+ const get = (name: string) => env[name]?.trim() || null;
98
+ const key = get('CARACMS_CLOUD_KEY');
99
+ if (!key || get('CARACMS_MEDIA') === 'memory') return null;
100
+ const problems: string[] = [];
101
+ const both = OWN_MEDIA_VARIABLES.filter((name) => get(name));
102
+ if (both.length) problems.push(CLOUD_AND_MEDIA_PROBLEM(both));
103
+ const url = readCloudUrl(get('CARACMS_CLOUD_URL'), problems);
104
+ return { connection: { key, url }, problems };
105
+ }
106
+
107
+ /** The media settings from the held cloud settings (or why there are none). */
108
+ function cloudMediaConfig(cloud: NonNullable<ReturnType<typeof cloudMedia>>, problems: string[]): MediaConfig {
109
+ problems.push(...cloud.problems);
110
+ if (problems.length) return { kind: 'unset', missing: [], problems };
111
+ const held = heldCloudSettings(cloud.connection);
112
+ if (!held) return { kind: 'unset', missing: [], problems: [CLOUD_MEDIA_NOT_FETCHED] };
113
+ if (held.kind === 'failed') return { kind: 'unset', missing: [], problems: [held.refusal.error === 'unreachable' ? CLOUD_MEDIA_UNREACHABLE : held.refusal.message] };
114
+ const media = held.settings.media;
115
+ if (!media) return { kind: 'unset', missing: [], problems: [CLOUD_NO_MEDIA] };
116
+ return { kind: 'ready', settings: { origin: `https://${media.imageHost}`, key: media.key, salt: media.salt, bucket: media.bucket, prefix: media.folder, memory: false } };
117
+ }
118
+
73
119
  /** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
74
120
  export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
75
121
  const get = (name: string) => {
@@ -83,6 +129,8 @@ export function readMediaConfig(env: Env = process.env, production = isProductio
83
129
  return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
84
130
  }
85
131
  if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
132
+ const cloud = cloudMedia(env);
133
+ if (cloud) return cloudMediaConfig(cloud, problems);
86
134
  const missing: string[] = [];
87
135
  const need = (name: string) => {
88
136
  const v = get(name);
@@ -131,8 +179,8 @@ function side(value: number | undefined): number {
131
179
  return Math.min(Math.round(value), IMAGE_MAX_DIMENSION);
132
180
  }
133
181
 
134
- /** A key as imgproxy's `plain` source takes it: each part percent-encoded, slashes kept. */
135
- const sourceOf = (settings: MediaSettings, key: string) => `s3://${settings.bucket}/${key.split('/').map(encodeURIComponent).join('/')}`;
182
+ /** A key as imgproxy's `plain` source takes it, after the cloud folder when there is one: each part percent-encoded, slashes kept. */
183
+ const sourceOf = (settings: MediaSettings, key: string) => `s3://${settings.bucket}/${`${settings.prefix ?? ''}${key}`.split('/').map(encodeURIComponent).join('/')}`;
136
184
 
137
185
  const signed = (settings: MediaSettings, path: string) => `${settings.origin}/${signPath(path, settings.key, settings.salt)}${path}`;
138
186