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/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