caracms 0.6.0 → 0.8.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 +147 -21
- package/bin/caracms.mjs +11 -3
- package/bin/init.mjs +332 -34
- package/bin/media.mjs +60 -6
- package/bin/templates/railway-cloud.ts.txt +73 -0
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +30 -13
- package/skills/caracms-hosting/SKILL.md +240 -0
- package/skills/caracms-tina/SKILL.md +45 -15
- package/src/cloud-middleware.ts +24 -5
- package/src/config.ts +20 -1
- package/src/editor/server/media/cloud-mux.ts +110 -0
- package/src/editor/server/media/config.ts +32 -8
- package/src/editor/server/media/handler.ts +5 -1
- package/src/editor/server/mode.ts +3 -1
- package/src/email.ts +4 -1
- package/src/forms/cloud-store.ts +212 -0
- package/src/forms/email.ts +52 -2
- package/src/forms/endpoint.ts +17 -5
- package/src/forms/store.ts +44 -4
- package/src/index.ts +3 -1
- package/src/live/auth/email.ts +22 -0
- package/src/live/mode.ts +50 -3
- package/src/live/services.ts +114 -0
- package/src/media.ts +9 -3
package/src/forms/store.ts
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* gets these. **The production server never falls back to disk**: with any of them
|
|
10
10
|
* unset, the store is `unset` (the endpoint's 502, the inbox's sentence).
|
|
11
11
|
* - **Everywhere else**: `.caracms/submissions/` under the site's root (gitignored),
|
|
12
|
-
* **even with the bucket variables set**, so a laptop never lands a test enquiry in
|
|
12
|
+
* **even with the bucket variables or the cloud key set**, so a laptop never lands a test enquiry in
|
|
13
13
|
* the client's inbox, nor copies their visitors' data down.
|
|
14
|
+
* - **CaraCMS Cloud** (caracms-cloud M4 Decision 23): a production server with
|
|
15
|
+
* `CARACMS_CLOUD_KEY` stores through the cloud's API (cloud-store.ts), and the cloud sends
|
|
16
|
+
* the email. The key with any of the site's own forms and email variables is `unset`
|
|
17
|
+
* with Decision 22's sentence.
|
|
14
18
|
* - **`CARACMS_SUBMISSIONS=memory`**: in memory, one per process, for tests. Refused in
|
|
15
19
|
* production, as `CARACMS_MEDIA=memory` is.
|
|
16
20
|
*
|
|
@@ -31,6 +35,9 @@ import { createHash, randomInt } from 'node:crypto';
|
|
|
31
35
|
import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
32
36
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
33
37
|
import { AwsClient } from 'aws4fetch';
|
|
38
|
+
import { cloudForms } from '../live/mode.ts';
|
|
39
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../live/services.ts';
|
|
40
|
+
import { cloudStore } from './cloud-store.ts';
|
|
34
41
|
import { isSubmissionId, shownFileName } from './rules.ts';
|
|
35
42
|
|
|
36
43
|
type Env = Record<string, string | undefined>;
|
|
@@ -77,8 +84,24 @@ export interface NewFile {
|
|
|
77
84
|
data: Uint8Array;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* What a cloud site's server sends with a submission (caracms-cloud M4 Decision 11): the
|
|
89
|
+
* email, written, or the reason there is none. The cloud sends it once the row is stored.
|
|
90
|
+
*/
|
|
91
|
+
export type WrittenEmail =
|
|
92
|
+
| { email: { to: string[]; subject: string; text: string; replyTo?: string; fromName: string } }
|
|
93
|
+
| { emailSkipped: string }
|
|
94
|
+
| { emailFailed: string };
|
|
95
|
+
|
|
80
96
|
export interface SubmissionStore {
|
|
81
|
-
|
|
97
|
+
/**
|
|
98
|
+
* `email` writes the email for the submission as stored (its id, files and time), and only
|
|
99
|
+
* the cloud store calls it (M4 Decision 23): it may call it twice, when the cloud already
|
|
100
|
+
* had the first id. The other stores ignore it; the endpoint sends their email itself.
|
|
101
|
+
*/
|
|
102
|
+
create(input: { form: string; receivedAt: string; fields: Record<string, string>; files?: NewFile[]; email?: (submission: StoredSubmission) => WrittenEmail }): Promise<StoredSubmission>;
|
|
103
|
+
/** True for the cloud store: the email goes with `create`, and the cloud sends it and records the outcome. */
|
|
104
|
+
readonly sendsEmail?: boolean;
|
|
82
105
|
get(id: string): Promise<StoredSubmission | null>;
|
|
83
106
|
getFile(id: string, n: number): Promise<{ file: StoredFile; body: Uint8Array } | null>;
|
|
84
107
|
/** Newest first, `limit` (default 50) after `before`; `older` is the cursor for the next page, or null. */
|
|
@@ -426,6 +449,8 @@ export type SubmissionsConfig =
|
|
|
426
449
|
| { kind: 'memory' }
|
|
427
450
|
| { kind: 'folder'; dir: string }
|
|
428
451
|
| { kind: 'bucket'; bucket: SubmissionsBucket }
|
|
452
|
+
/** CaraCMS Cloud (caracms-cloud M4 Decision 23): a production server with `CARACMS_CLOUD_KEY`. */
|
|
453
|
+
| { kind: 'cloud'; connection: { key: string; url: string } }
|
|
429
454
|
/** The production server without its bucket: `message` names what's missing (the endpoint's log, the inbox's sentence). */
|
|
430
455
|
| { kind: 'unset'; message: string; missing: string[] };
|
|
431
456
|
|
|
@@ -435,15 +460,28 @@ const list = (names: readonly string[]) => (names.length <= 1 ? names.join('') :
|
|
|
435
460
|
export const isProductionEnv = (env: Env): boolean => env['NODE_ENV'] === 'production';
|
|
436
461
|
|
|
437
462
|
/** Decision 7's rule, from the environment. Read on each call (cheap). */
|
|
438
|
-
export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env)): SubmissionsConfig {
|
|
463
|
+
export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env), services: Services | null = bakedServices()): SubmissionsConfig {
|
|
464
|
+
// caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
|
|
465
|
+
env = servicesEnv(env, services);
|
|
439
466
|
const get = (name: string) => env[name]?.trim() || '';
|
|
440
467
|
const mode = get('CARACMS_SUBMISSIONS');
|
|
441
468
|
if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
442
469
|
if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
|
|
470
|
+
// The key with the site's own variables is a mistake everywhere (M4 Decision 22), as the media's is.
|
|
471
|
+
const cloud = cloudForms(env, services);
|
|
472
|
+
if (cloud?.problems.length) return { kind: 'unset', message: cloud.problems.join(' '), missing: [] };
|
|
473
|
+
// Outside production: the folder, even with the cloud key (M4's Core framing).
|
|
443
474
|
if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
|
|
475
|
+
if (cloud) return { kind: 'cloud', connection: cloud.connection };
|
|
476
|
+
// M5 Decision 4: a cloud site without its key, never the site's own bucket instead.
|
|
477
|
+
if (services === 'cloud') return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: ['CARACMS_CLOUD_KEY'] };
|
|
444
478
|
const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
|
|
445
479
|
if (missing.length) {
|
|
446
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
kind: 'unset',
|
|
482
|
+
message: `Form submissions aren't set up on this server: set CARACMS_CLOUD_KEY to the site's CaraCMS Cloud key, or set ${list(missing)} to the site's submissions bucket.`,
|
|
483
|
+
missing,
|
|
484
|
+
};
|
|
447
485
|
}
|
|
448
486
|
return {
|
|
449
487
|
kind: 'bucket',
|
|
@@ -474,6 +512,8 @@ export function resetSubmissions(): void {
|
|
|
474
512
|
/** The store for this config, one per process per backend (so the unread count is kept), or the `unset` config itself. */
|
|
475
513
|
export function submissionStore(config: SubmissionsConfig, fetchImpl?: typeof fetch): SubmissionStore | Extract<SubmissionsConfig, { kind: 'unset' }> {
|
|
476
514
|
if (config.kind === 'unset') return config;
|
|
515
|
+
// One per call (a request): the cloud keeps the unread count, and the store holds only the count its own last answer gave.
|
|
516
|
+
if (config.kind === 'cloud') return cloudStore({ ...config.connection, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
477
517
|
const s = shared();
|
|
478
518
|
const id = config.kind === 'memory' ? 'memory' : config.kind === 'folder' ? `folder:${config.dir}` : `bucket:${config.bucket.endpoint}:${config.bucket.bucket}:${config.bucket.accessKeyId}`;
|
|
479
519
|
let store = s.stores.get(id);
|
package/src/index.ts
CHANGED
|
@@ -466,6 +466,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
466
466
|
// Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
|
|
467
467
|
__CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
|
|
468
468
|
__CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
|
|
469
|
+
// caracms-cloud M5 Decision 1: the config's `services`, for the key-or-own readers (live/services.ts → bakedServices).
|
|
470
|
+
__CARACMS_SERVICES__: JSON.stringify(site.services),
|
|
469
471
|
__CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
|
|
470
472
|
},
|
|
471
473
|
// Installed from npm, the package ships TypeScript and .astro source.
|
|
@@ -518,7 +520,7 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
518
520
|
// caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
|
|
519
521
|
'astro:build:start': async () => {
|
|
520
522
|
shareBuildReads(true);
|
|
521
|
-
await cloudSettingsForBuild();
|
|
523
|
+
await cloudSettingsForBuild(process.env, site.services);
|
|
522
524
|
},
|
|
523
525
|
'astro:build:done': () => {
|
|
524
526
|
shareBuildReads(false);
|
package/src/live/auth/email.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* server log and nothing is sent, so no test from a laptop reaches anyone's inbox.
|
|
8
8
|
* The email names the site by its brand name. The link carries only the token.
|
|
9
9
|
*
|
|
10
|
+
* **On CaraCMS Cloud** (caracms-cloud M4 Decision 25), a production server with
|
|
11
|
+
* `CARACMS_CLOUD_KEY` asks the cloud instead (`POST /v1/email/sign-in`), which writes the
|
|
12
|
+
* email from its own copy of this template and sends it from `accounts.caracms.com`. The
|
|
13
|
+
* key beside `RESEND_API_KEY` or `FORMS_FROM` (Decision 22) sends nothing and logs why.
|
|
14
|
+
* Outside production the link is logged, key or not.
|
|
15
|
+
*
|
|
10
16
|
* The HTML part is one table layout with inline styles (mail clients strip `<style>`),
|
|
11
17
|
* the system font stack, no images; the plain-text part says the same.
|
|
12
18
|
*/
|
|
13
19
|
import { sendEmail, sendsEmail } from '../../email.ts';
|
|
20
|
+
import { cloudCall, isCloudRefusal } from '../cloud.ts';
|
|
21
|
+
import { cloudForms } from '../mode.ts';
|
|
14
22
|
|
|
15
23
|
export { RESEND_URL } from '../../email.ts';
|
|
16
24
|
const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
@@ -76,6 +84,20 @@ export async function deliverLink(input: {
|
|
|
76
84
|
log(`sign-in link for ${to} (not emailed outside production): ${link}`);
|
|
77
85
|
return 'logged';
|
|
78
86
|
}
|
|
87
|
+
const cloud = cloudForms(env);
|
|
88
|
+
if (cloud) {
|
|
89
|
+
if (cloud.problems.length) {
|
|
90
|
+
log(`could not email a sign-in link: ${cloud.problems.join(' ')}`);
|
|
91
|
+
return 'failed';
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await cloudCall({ ...cloud.connection, ...(input.fetch ? { fetch: input.fetch } : {}) }, 'POST', '/v1/email/sign-in', { to, link, siteName });
|
|
95
|
+
return 'sent';
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log(`could not email a sign-in link: CaraCMS Cloud${isCloudRefusal(error) ? ` (${error.error}): ${error.message}` : `: ${error instanceof Error ? error.message : String(error)}`}`);
|
|
98
|
+
return 'failed';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
79
101
|
const { subject, text, html } = buildLinkEmail({ siteName, link, email: to });
|
|
80
102
|
const result = await sendEmail({ to: [to], subject, text, html }, { env, fetch: input.fetch });
|
|
81
103
|
if (result.status === 'sent') return 'sent';
|
package/src/live/mode.ts
CHANGED
|
@@ -17,12 +17,20 @@
|
|
|
17
17
|
* browser runs, never against the site's own repo. It's live mode with no App and
|
|
18
18
|
* no repo, and it's **refused in production**.
|
|
19
19
|
*
|
|
20
|
+
* - **The config's `services`** (caracms-cloud M5, live/services.ts, baked in as `Baked.services`):
|
|
21
|
+
* with it, the other mode's variables are removed before anything here reads them, so the
|
|
22
|
+
* "not both" sentences below only ever apply to a config without the field (Decision 3);
|
|
23
|
+
* `'cloud'` without the key is `CLOUD_KEY_MISSING_PROBLEM` on a production server (Decision 4).
|
|
24
|
+
*
|
|
20
25
|
* This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
|
|
21
26
|
* Phase 3 guards with it).
|
|
22
27
|
*/
|
|
23
28
|
import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
|
|
24
29
|
import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
|
|
25
30
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
31
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES, servicesEnv, type Services } from './services.ts';
|
|
32
|
+
|
|
33
|
+
export { OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES };
|
|
26
34
|
|
|
27
35
|
export interface LiveSettings {
|
|
28
36
|
/** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
|
|
@@ -81,6 +89,8 @@ export interface Baked {
|
|
|
81
89
|
site?: string | null;
|
|
82
90
|
/** The commit the build was made from (runs-anywhere Decision 9). */
|
|
83
91
|
commit?: string | null;
|
|
92
|
+
/** The config's `services` (caracms-cloud M5 Decision 1; live/services.ts). Null or missing: the key decides (Decision 3). */
|
|
93
|
+
services?: Services | null;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
// The sentences the configuration problems print, kept here so the docs can quote them.
|
|
@@ -97,10 +107,35 @@ export const CLOUD_KEY_PROBLEM =
|
|
|
97
107
|
"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
108
|
export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
|
|
99
109
|
`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
110
|
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
103
111
|
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)}.`;
|
|
112
|
+
export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
|
|
113
|
+
export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
|
|
114
|
+
|
|
115
|
+
/** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
|
|
116
|
+
export function cloudFormsConflict(env: Env): string | null {
|
|
117
|
+
if (!env.CARACMS_CLOUD_KEY?.trim()) return null;
|
|
118
|
+
const both = OWN_FORMS_VARIABLES.filter((name) => env[name]?.trim());
|
|
119
|
+
return both.length ? CLOUD_AND_FORMS_PROBLEM(both) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The cloud as the forms and email credential (caracms-cloud M4 Decision 22): the
|
|
124
|
+
* connection when `CARACMS_CLOUD_KEY` is set, with any problems (the site's own forms and
|
|
125
|
+
* email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
|
|
126
|
+
* the cloud is *used* is the caller's (only on a production server; the Core framing).
|
|
127
|
+
*/
|
|
128
|
+
export function cloudForms(env: Env, services: Services | null = bakedServices()): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
129
|
+
env = servicesEnv(env, services);
|
|
130
|
+
const get = (name: string) => env[name]?.trim() || null;
|
|
131
|
+
const key = get('CARACMS_CLOUD_KEY');
|
|
132
|
+
if (!key) return null;
|
|
133
|
+
const conflict = cloudFormsConflict(env);
|
|
134
|
+
const problems: string[] = conflict ? [conflict] : [];
|
|
135
|
+
const url = readCloudUrl(get('CARACMS_CLOUD_URL'), problems);
|
|
136
|
+
return { connection: { key, url }, problems };
|
|
137
|
+
}
|
|
138
|
+
|
|
104
139
|
export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
|
|
105
140
|
export const CLOUD_URL_PROBLEM = (url: string) =>
|
|
106
141
|
`CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
|
|
@@ -162,6 +197,9 @@ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.lo
|
|
|
162
197
|
* `baked` is what the build baked in (the address and the commit).
|
|
163
198
|
*/
|
|
164
199
|
export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
|
|
200
|
+
// caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
|
|
201
|
+
const services = baked.services ?? null;
|
|
202
|
+
env = servicesEnv(env, services);
|
|
165
203
|
const get = (name: string) => {
|
|
166
204
|
const v = env[name]?.trim();
|
|
167
205
|
return v ? v : null;
|
|
@@ -185,7 +223,10 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
185
223
|
if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
|
|
186
224
|
|
|
187
225
|
let github: LiveSettings['github'] = { kind: 'memory' };
|
|
188
|
-
if (cloudKey && (!memory || production)) {
|
|
226
|
+
if (services === 'cloud' && !cloudKey && (!memory || production)) {
|
|
227
|
+
// M5 Decision 4: never the self-hosted variables instead (they were removed above).
|
|
228
|
+
problems.push(CLOUD_KEY_MISSING_PROBLEM);
|
|
229
|
+
} else if (cloudKey && (!memory || production)) {
|
|
189
230
|
// The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
|
|
190
231
|
if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
|
|
191
232
|
if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
|
|
@@ -194,6 +235,12 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
194
235
|
// M3 Decision 19: the key is the media credential too (unless the tests' in-memory media is on).
|
|
195
236
|
const ownMedia = OWN_MEDIA_VARIABLES.filter((name) => get(name));
|
|
196
237
|
if (ownMedia.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MEDIA_PROBLEM(ownMedia));
|
|
238
|
+
// M4.5 Decision 15: and the video credential.
|
|
239
|
+
const ownMux = OWN_MUX_VARIABLES.filter((name) => get(name));
|
|
240
|
+
if (ownMux.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MUX_PROBLEM(ownMux));
|
|
241
|
+
// M4 Decision 22: and the forms and email credential.
|
|
242
|
+
const formsConflict = cloudFormsConflict(env);
|
|
243
|
+
if (formsConflict) problems.push(formsConflict);
|
|
197
244
|
github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
|
|
198
245
|
} else if (!memory || production) {
|
|
199
246
|
const appId = get('GITHUB_APP_ID');
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the site's services come from (caracms-cloud M5): the config's `services` field,
|
|
3
|
+
* not whichever variables happen to be set.
|
|
4
|
+
*
|
|
5
|
+
* - **`'cloud'`**: `CARACMS_CLOUD_KEY` is the one credential. The self-hosted variables
|
|
6
|
+
* (`REPLACED_VARIABLES`) are **removed from the environment the readers see**, so none is
|
|
7
|
+
* ever read, not even as a fallback (Decision 2, check 5), and each is named once per
|
|
8
|
+
* process as ignored.
|
|
9
|
+
* - **`'self-hosted'`**: the site's own variables; `CARACMS_CLOUD_KEY` is removed and named
|
|
10
|
+
* as ignored the same way.
|
|
11
|
+
* - **Unset**: the environment as it is, so the key decides and the "not both" refusals
|
|
12
|
+
* stand (Decision 3).
|
|
13
|
+
*
|
|
14
|
+
* Every key-or-own reader (live/mode.ts `readMode` and `cloudForms`, media.ts, the editor's
|
|
15
|
+
* media config, forms/store.ts, cloud-middleware.ts, and `media import` in bin/media.mjs)
|
|
16
|
+
* starts from `servicesEnv`. The running site's value is baked in at build
|
|
17
|
+
* (`__CARACMS_SERVICES__`, a Vite define from the config: `bakedServices`), since the readers
|
|
18
|
+
* are synchronous and have no config in hand.
|
|
19
|
+
*/
|
|
20
|
+
import type { Services } from '../config.ts';
|
|
21
|
+
|
|
22
|
+
export type { Services };
|
|
23
|
+
type Env = Record<string, string | undefined>;
|
|
24
|
+
|
|
25
|
+
/** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
|
|
26
|
+
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;
|
|
27
|
+
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
28
|
+
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
29
|
+
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
30
|
+
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
31
|
+
|
|
32
|
+
/** The site's own GitHub variables, which the cloud key replaces (caracms-cloud M2 Decision 11). `GITHUB_WEBHOOK_SECRET` is harmless and not here. */
|
|
33
|
+
export const OWN_GITHUB_VARIABLES = ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO'] as const;
|
|
34
|
+
|
|
35
|
+
/** Every variable the cloud replaces, by group, in the move list's order of retiring (M5 Contracts). */
|
|
36
|
+
export const REPLACED_VARIABLES = {
|
|
37
|
+
media: OWN_MEDIA_VARIABLES,
|
|
38
|
+
video: OWN_MUX_VARIABLES,
|
|
39
|
+
forms: OWN_FORMS_VARIABLES,
|
|
40
|
+
github: OWN_GITHUB_VARIABLES,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** What each group's ignored line ends with: when it can go. */
|
|
44
|
+
const RETIRE: Record<keyof typeof REPLACED_VARIABLES, string> = {
|
|
45
|
+
media: 'Remove them once the old bucket and imgproxy are retired.',
|
|
46
|
+
video: 'Remove them once every clip is uploaded again in the editor.',
|
|
47
|
+
forms: 'Remove them once the old submissions are exported or no longer wanted.',
|
|
48
|
+
github: 'Remove them: the cloud gives the site its repo and access.',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The sentence Decision 4 fails a production build and `/admin` with. */
|
|
52
|
+
export const CLOUD_KEY_MISSING_PROBLEM = "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com.";
|
|
53
|
+
|
|
54
|
+
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
55
|
+
|
|
56
|
+
/** The ignored line for some of one group's variables (cloud), or for the key (self-hosted). */
|
|
57
|
+
export function ignoredMessage(names: readonly string[], services: Services, group?: keyof typeof REPLACED_VARIABLES): string {
|
|
58
|
+
const one = names.length === 1;
|
|
59
|
+
const set = `${listed(names)} ${one ? 'is' : 'are'} set`;
|
|
60
|
+
if (services === 'self-hosted') return `${set}, but this site is self-hosted (caracms.config.ts): ${one ? "it's" : "they're"} ignored. Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.`;
|
|
61
|
+
const tail = group ? RETIRE[group] : 'Remove them once the move is done.';
|
|
62
|
+
return `${set}, but this site runs on CaraCMS Cloud (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${one ? tail.replace(/them/g, 'it') : tail}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const WARNED = Symbol.for('caracms.ignoredVariables');
|
|
66
|
+
type Shared = { [WARNED]?: Set<string> };
|
|
67
|
+
const warned = () => ((globalThis as Shared)[WARNED] ??= new Set());
|
|
68
|
+
|
|
69
|
+
/** Forget which variables were named (tests). */
|
|
70
|
+
export function resetIgnoredWarnings(): void {
|
|
71
|
+
delete (globalThis as Shared)[WARNED];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const set = (env: Env, name: string) => Boolean(env[name]?.trim());
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The environment the readers see for this `services` (null: the environment as it is),
|
|
78
|
+
* with the other mode's variables removed. Each removed variable is named once per process
|
|
79
|
+
* (shared on globalThis, so the build's own module and its bundled chunks warn once between them).
|
|
80
|
+
*/
|
|
81
|
+
export function servicesEnv(env: Env, services: Services | null | undefined, log: (line: string) => void = (line) => console.warn(line)): Env {
|
|
82
|
+
if (!services) return env;
|
|
83
|
+
const seen = warned();
|
|
84
|
+
const out: Env = { ...env };
|
|
85
|
+
if (services === 'self-hosted') {
|
|
86
|
+
if (!set(env, 'CARACMS_CLOUD_KEY')) return env;
|
|
87
|
+
delete out.CARACMS_CLOUD_KEY;
|
|
88
|
+
if (!seen.has('CARACMS_CLOUD_KEY')) log(`[caracms] ${ignoredMessage(['CARACMS_CLOUD_KEY'], services)}`);
|
|
89
|
+
seen.add('CARACMS_CLOUD_KEY');
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
for (const [group, names] of Object.entries(REPLACED_VARIABLES) as [keyof typeof REPLACED_VARIABLES, readonly string[]][]) {
|
|
93
|
+
const found = names.filter((name) => set(env, name));
|
|
94
|
+
for (const name of found) delete out[name];
|
|
95
|
+
const fresh = found.filter((name) => !seen.has(name));
|
|
96
|
+
if (fresh.length) log(`[caracms] ${ignoredMessage(fresh, services, group)}`);
|
|
97
|
+
for (const name of fresh) seen.add(name);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The names `servicesEnv` would ignore in `env` for `services` (init's move list, tests). */
|
|
103
|
+
export function ignoredVariables(env: Env, services: Services | null | undefined): string[] {
|
|
104
|
+
if (!services) return [];
|
|
105
|
+
if (services === 'self-hosted') return set(env, 'CARACMS_CLOUD_KEY') ? ['CARACMS_CLOUD_KEY'] : [];
|
|
106
|
+
return Object.values(REPLACED_VARIABLES).flatMap((names) => names.filter((name) => set(env, name)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
declare const __CARACMS_SERVICES__: Services | null | undefined;
|
|
110
|
+
|
|
111
|
+
/** The config's `services` as the build baked it in; null in plain Node (tests) and for a config without one. */
|
|
112
|
+
export function bakedServices(): Services | null {
|
|
113
|
+
return typeof __CARACMS_SERVICES__ === 'string' ? __CARACMS_SERVICES__ : null;
|
|
114
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { createHmac } from 'node:crypto';
|
|
|
35
35
|
import type { ImageFormat } from './fields.ts';
|
|
36
36
|
import { CLOUD_NO_MEDIA, heldCloudSettings, type CloudConnection } from './live/cloud.ts';
|
|
37
37
|
import { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES, readCloudUrl } from './live/mode.ts';
|
|
38
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
|
|
38
39
|
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
39
40
|
|
|
40
41
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
@@ -92,8 +93,10 @@ export const CLOUD_MEDIA_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so im
|
|
|
92
93
|
* The cloud as the media credential: the connection when `CARACMS_CLOUD_KEY` is set and
|
|
93
94
|
* `CARACMS_MEDIA` isn't `memory` (which wins over everything), with any problems (the
|
|
94
95
|
* site's own media variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site's media.
|
|
96
|
+
* With the config's `services` (caracms-cloud M5), the other mode's variables aren't read.
|
|
95
97
|
*/
|
|
96
|
-
export function cloudMedia(env: Env = process.env): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
98
|
+
export function cloudMedia(env: Env = process.env, services: Services | null = bakedServices()): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
99
|
+
env = servicesEnv(env, services);
|
|
97
100
|
const get = (name: string) => env[name]?.trim() || null;
|
|
98
101
|
const key = get('CARACMS_CLOUD_KEY');
|
|
99
102
|
if (!key || get('CARACMS_MEDIA') === 'memory') return null;
|
|
@@ -117,7 +120,8 @@ function cloudMediaConfig(cloud: NonNullable<ReturnType<typeof cloudMedia>>, pro
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
|
|
120
|
-
export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
|
|
123
|
+
export function readMediaConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MediaConfig {
|
|
124
|
+
env = servicesEnv(env, services);
|
|
121
125
|
const get = (name: string) => {
|
|
122
126
|
const v = env[name]?.trim();
|
|
123
127
|
return v ? v : null;
|
|
@@ -129,7 +133,9 @@ export function readMediaConfig(env: Env = process.env, production = isProductio
|
|
|
129
133
|
return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
|
|
130
134
|
}
|
|
131
135
|
if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
|
|
132
|
-
|
|
136
|
+
// M5 Decision 4: a cloud site without its key has no media, never the site's own instead.
|
|
137
|
+
if (services === 'cloud' && !get('CARACMS_CLOUD_KEY')) return { kind: 'unset', missing: [], problems: [...problems, CLOUD_KEY_MISSING_PROBLEM] };
|
|
138
|
+
const cloud = cloudMedia(env, services);
|
|
133
139
|
if (cloud) return cloudMediaConfig(cloud, problems);
|
|
134
140
|
const missing: string[] = [];
|
|
135
141
|
const need = (name: string) => {
|