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/README.md +63 -11
- package/bin/caracms.mjs +5 -2
- package/bin/media.mjs +308 -30
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +3 -1
- package/src/cloud-middleware.ts +76 -0
- package/src/editor/README.md +2 -1
- package/src/editor/server/media/cloud-bucket.ts +144 -0
- package/src/editor/server/media/config.ts +11 -1
- package/src/editor/server/media/handler.ts +8 -0
- package/src/files-route.ts +2 -1
- package/src/index.ts +53 -4
- package/src/live/README.md +1 -1
- package/src/live/auth/middleware.ts +2 -2
- package/src/live/cloud.ts +151 -18
- package/src/live/github.ts +2 -2
- package/src/live/mode.ts +8 -1
- package/src/live/runtime.ts +15 -2
- package/src/media.ts +50 -2
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
|
-
* - `
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
90
|
+
let answer: unknown;
|
|
63
91
|
try {
|
|
64
92
|
res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
|
|
65
93
|
method,
|
|
66
|
-
headers: {
|
|
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
|
-
|
|
103
|
+
answer = await res.json();
|
|
70
104
|
} catch {
|
|
71
105
|
throw unreachable();
|
|
72
106
|
}
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
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 } =
|
|
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
|
|
114
|
+
return object;
|
|
81
115
|
}
|
|
82
116
|
|
|
83
117
|
const REPO = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
|
|
84
118
|
|
|
85
|
-
/**
|
|
86
|
-
export
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
package/src/live/github.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/live/runtime.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|