caracms 0.5.1 → 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 +10 -1
- 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/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
|
|