caracms 0.5.1 → 0.7.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 CHANGED
@@ -53,7 +53,10 @@ Run from the site's root:
53
53
  content file out as the editor saves it (two-space JSON; only whitespace changes).
54
54
  - `caracms schemas`: write `schemas/` from the config, and give each content file its
55
55
  `$schema`.
56
- - `caracms media import <files…>`: put local images into the site's media bucket.
56
+ - `caracms media import <files…>`: put local images into the site's media bucket (or, with
57
+ `CARACMS_CLOUD_KEY`, its folder in CaraCMS Cloud); prints `[{ file, key, w, h, existed }]`.
58
+ - `caracms media push`: copy a site's originals from its own bucket into CaraCMS Cloud,
59
+ keeping every key ([Moving a site's images in](#moving-a-sites-images-in)).
57
60
  - `caracms start`: serve the built site (Hosting, below).
58
61
 
59
62
  The skills (`caracms-content`, `caracms-blocks` and `caracms-adopt`, in `skills/`) say how
@@ -126,7 +129,7 @@ These go on the site's server. `caracms init` prints the same list.
126
129
 
127
130
  | Name | Needed | What it's for |
128
131
  |---|---|---|
129
- | `CARACMS_CLOUD_KEY` | instead of the GitHub variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App and `GITHUB_REPO`: see [CaraCMS Cloud](#caracms-cloud). |
132
+ | `CARACMS_CLOUD_KEY` | instead of the GitHub, image, form and email variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App, `GITHUB_REPO`, the image variables, and the form and email ones: see [CaraCMS Cloud](#caracms-cloud). |
130
133
  | `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
131
134
  | `CARACMS_GITHUB_TOKEN` | required, or the App, or the cloud key | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
132
135
  | `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
@@ -136,8 +139,8 @@ These go on the site's server. `caracms init` prints the same list.
136
139
  | `CARACMS_SECRET` | required | Signs the editors' sessions. A long random string. |
137
140
  | `CARACMS_CLIENT_IP_HEADER` | required off Railway, Fly and Render | Where the visitor's IP comes from: `x-real-ip` for nginx with `proxy_set_header X-Real-IP $remote_addr`, `x-forwarded-for` for Caddy, or `socket` if nothing sits in front of this server. `caracms start` won't start without it. |
138
141
  | `CARACMS_ALLOW` | for email sign-in | Who may sign in, comma-separated: addresses (`aoife@fernhill.ie`) or whole domains (`@fernhill.ie`). The client adds their own people in Site Settings. |
139
- | `RESEND_API_KEY` | for email sign-in | Sends the sign-in links (and form notifications) through Resend. |
140
- | `FORMS_FROM` | for email sign-in | The address those emails come from, on a domain verified in Resend. |
142
+ | `RESEND_API_KEY` | for email sign-in, but not with the cloud key | Sends the sign-in links (and form notifications) through Resend. |
143
+ | `FORMS_FROM` | for email sign-in, but not with the cloud key | The address those emails come from, on a domain verified in Resend. |
141
144
  | `CARACMS_BACKUP_USER` | for the backup sign-in | The backup sign-in, for when email sign-in links can't arrive. Both or neither. |
142
145
  | `CARACMS_BACKUP_PASSWORD` | for the backup sign-in | The backup sign-in's password. |
143
146
  | `CARACMS_RUNNING_COMMIT` | optional | The commit this server runs, where the host doesn't say (Railway and Render do). |
@@ -151,10 +154,10 @@ A site that uses images, video or forms needs these as well. The image variables
151
154
 
152
155
  | Feature | Variables |
153
156
  |---|---|
154
- | Images (the media bucket and imgproxy) | `CARACMS_MEDIA_URL` (imgproxy's address), `IMGPROXY_KEY`, `IMGPROXY_SALT`, `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
155
- | Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` |
156
- | Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY` |
157
- | Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above) |
157
+ | Images (the media bucket and imgproxy) | `CARACMS_MEDIA_URL` (imgproxy's address), `IMGPROXY_KEY`, `IMGPROXY_SALT`, `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
158
+ | Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
159
+ | Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
160
+ | Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above); on CaraCMS Cloud, none of them |
158
161
  | Spam checks on forms | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` |
159
162
  | A password on the whole site before launch | `CARACMS_PRELAUNCH` |
160
163
  | Try again through Railway's API | `CARACMS_RAILWAY_TOKEN` (Railway sets the rest) |
@@ -327,11 +330,13 @@ repo, and only that repo, about once an hour.
327
330
 
328
331
  The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
329
332
  and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
330
- `GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well.
333
+ `GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well. It
334
+ also replaces the image variables ([Images](#images-on-caracms-cloud), below), the
335
+ video ones ([Video](#video-on-caracms-cloud)), and the form and email ones
336
+ ([Forms and email](#forms-and-email-on-caracms-cloud)).
331
337
  Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
332
- `REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the image, video and form
333
- variables. There's no webhook: a check once a minute keeps the editor's Publish status up
334
- to date.
338
+ `REDIS_URL`, `CARACMS_SECRET`, `CARACMS_ALLOW`, the backup sign-in, and Turnstile.
339
+ There's no webhook: a check once a minute keeps the editor's Publish status up to date.
335
340
 
336
341
  If the cloud says no when the server starts (the App isn't installed on the repo, the
337
342
  site has no repo yet, the key was revoked), `/admin` answers 503 with the cloud's own
@@ -340,10 +345,115 @@ says no later, while the editor is open, the editor shows the same sentence unti
340
345
  cloud gives a token again. A repo changed in CaraCMS Cloud reaches the site at its next
341
346
  start.
342
347
 
343
- **On a laptop**, a cloud key turns `astro dev` into live mode on the site's real repo,
344
- as the App's variables do. Use a key from CaraCMS Cloud's dev deployment there (set its
345
- address as `CARACMS_CLOUD_URL`), never a production key: the dev deployment only gives
346
- out tokens for throwaway repos. Leave `CARACMS_CLOUD_URL` unset everywhere else.
348
+ #### Images on CaraCMS Cloud
349
+
350
+ With the key, the site's images live in its own folder of CaraCMS Cloud's media bucket and
351
+ are served, resized and signed, from `img.caracms.cloud`. Set none of `S3_ENDPOINT`,
352
+ `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `IMGPROXY_KEY`,
353
+ `IMGPROXY_SALT` or `CARACMS_MEDIA_URL`: setting any of them beside the key is refused with
354
+
355
+ > Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove S3_BUCKET.
356
+
357
+ The image host, the bucket, the site's folder and its signing key come from the cloud:
358
+ once when the site is built, and once when its server starts (on its first request). The
359
+ signing key is never in a variable, a log or anything sent to a browser. Uploads go to the
360
+ cloud through short-lived links the cloud makes for the site's server, and count towards
361
+ the site's storage limit; a full site's uploads are refused with the cloud's sentence. The
362
+ keys in content are the same `uploads/<hash>/<name>.<ext>` as anywhere else, so leaving
363
+ the cloud is copying the folder to a bucket of your own.
364
+
365
+ **A build needs the cloud.** If it says no (a revoked key, a team switched off), the build
366
+ fails with its sentence; if it can't be reached, with "CaraCMS Cloud couldn't be reached,
367
+ so the site can't be built. Try again in a minute." A site with no repo linked yet still
368
+ builds. A running site keeps serving whatever the cloud does.
369
+
370
+ `caracms media import <files…>` works with the key too (from the environment or the
371
+ site's `.env`), with the same rules and output, and its uploads count like any other.
372
+
373
+ #### Forms and email on CaraCMS Cloud
374
+
375
+ With the key, the site needs no submissions bucket and no Resend account. Set none of
376
+ `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`,
377
+ `RESEND_API_KEY` or `FORMS_FROM`: setting any of them beside the key is refused, at build
378
+ and when the server starts, with
379
+
380
+ > Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove RESEND_API_KEY and FORMS_FROM.
381
+
382
+ The site's server still makes every check on a submission (the fields, the honeypot, the
383
+ rate limit, Turnstile) and writes its email, as before. The cloud stores the submission
384
+ and its attachments, and sends the email once it's stored. The editor's inbox reads them
385
+ from the cloud, unchanged.
386
+
387
+ - **Form emails** come from `forms@notify.caracms.com`, named "<brand name> – <form label>"
388
+ (for example "Urban Return – Contact"), with Reply-To the visitor. They go to the form's
389
+ recipients in Site Settings, at most the first ten (the server log says when some were
390
+ left off).
391
+ - **Each site sends at most 50 form emails a day** (UTC) unless CaraCMS raises it. Past
392
+ that, a submission is still stored, and the inbox shows it as "Not emailed: this site
393
+ reached its daily email limit in CaraCMS Cloud (50). Ask CaraCMS to raise it." It isn't
394
+ sent later.
395
+ - **Sign-in links** come from `no-reply@accounts.caracms.com`, in the same words as before.
396
+
397
+ **On a laptop**, or anywhere that isn't a production server (`NODE_ENV=production`),
398
+ nothing goes to the cloud, even with the key: submissions are kept in
399
+ `.caracms/submissions/` and both emails are written to the server log, so a test enquiry
400
+ never reaches a client's inbox.
401
+
402
+ A site on an older caracms keeps its own bucket and Resend until it updates and removes
403
+ them.
404
+
405
+ #### Moving a site's images in
406
+
407
+ `caracms media push` copies everything under `uploads/` in the site's own bucket into its
408
+ CaraCMS Cloud folder, under the same keys, so the content doesn't change. It's the one
409
+ command that takes both credentials, so give the site's own `S3_*` on its command line
410
+ rather than in `.env` (the running site refuses both):
411
+
412
+ ```bash
413
+ S3_ENDPOINT=https://t3.storageapi.dev S3_REGION=auto S3_BUCKET=… S3_ACCESS_KEY_ID=… S3_SECRET_ACCESS_KEY=… npx caracms media push
414
+ ```
415
+
416
+ It checks each file's bytes against the hash in its key, skips anything already in the
417
+ cloud (so it can be stopped and run again), carries over each file's size, name and
418
+ uploader, and never changes the source bucket. It prints `{ copied, skipped, failed }`.
419
+ A site bigger than its storage limit stops at the limit with the cloud's sentence: ask
420
+ CaraCMS to raise it, then run it again.
421
+
422
+ #### Video on CaraCMS Cloud
423
+
424
+ With the key, the site's clips are uploaded to CaraCMS Cloud's Mux and listed from the
425
+ site's own clips there; the editor works exactly as it does with a Mux account of your
426
+ own. Set neither `MUX_TOKEN_ID` nor `MUX_TOKEN_SECRET`: setting either beside the key is
427
+ refused with
428
+
429
+ > Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove MUX_TOKEN_ID.
430
+
431
+ Clips play on the site straight from Mux (`stream.mux.com`, `image.mux.com`), as before:
432
+ the public pages never call the cloud. The minutes a site stores count towards its video
433
+ limit (60 minutes unless CaraCMS has set another), and once it's reached, a new upload is
434
+ refused with the cloud's sentence: "This site has reached its video limit in CaraCMS
435
+ Cloud (60 minutes). Ask CaraCMS to raise it." A clip counts once Mux has finished with
436
+ it, so one upload can take a site past its limit; the next is refused.
437
+
438
+ **Moving a site's video in** means uploading its clips again in the editor and placing
439
+ them where the old ones were. There's no copy command. Until a clip is replaced, the old
440
+ one keeps playing on the site from the site's old Mux account: the page only needs its
441
+ playback ID. In the editor it shows its poster and "A clip" (it isn't in the cloud's
442
+ library), and saving other changes on the page leaves it as it is. Remove `MUX_TOKEN_ID`
443
+ and `MUX_TOKEN_SECRET` when you set the key; keep the old Mux account until every clip
444
+ has been replaced.
445
+
446
+ #### Working locally
447
+
448
+ **Anyone working on a real site, wherever they are, uses that site's CaraCMS Cloud**: your
449
+ laptop, a colleague's, a client's developer, a staging deploy. Each gets **its own key**
450
+ for the site, labelled ("Rich's laptop", "staging") in app.caracms.com, so revoking one
451
+ never touches the live site. Leave `CARACMS_CLOUD_URL` unset: images render from
452
+ `img.caracms.cloud` and the library lists the site's real folder. A cloud key turns
453
+ `astro dev` into live mode on the site's real repo, as the App's variables do. Uploads
454
+ from a laptop land in the real folder and count towards its storage, which is harmless:
455
+ nothing is overwritten or deleted, and nothing is live until it's published.
456
+ `CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment.
347
457
 
348
458
  ### Behind Cloudflare
349
459
 
package/bin/caracms.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The `caracms` command (M8 → Contracts): `init`, `schemas`, `validate`, `media import`
2
+ // The `caracms` command (M8 → Contracts): `init`, `schemas`, `validate`, `media import`, `media push`
3
3
  // and `start`, each run from the site's root. Exit codes: 0 fine, 1 problems found or
4
4
  // refused, 2 bad usage.
5
5
  //
@@ -36,7 +36,10 @@ const USAGE = `Usage: caracms <command>, from the site's root
36
36
  validate [--fix] [--json] Check content against caracms.config.ts. --fix runs \`schemas\` first,
37
37
  then lays content files out as the editor saves them;
38
38
  --json prints { errors, warnings }.
39
- media import <files…> Put local images into the site's media bucket; prints JSON.
39
+ media import <files…> Put local images into the site's media bucket (or its CaraCMS Cloud
40
+ folder, with CARACMS_CLOUD_KEY); prints JSON.
41
+ media push Copy the site's own bucket's uploads/ into its CaraCMS Cloud folder,
42
+ keeping every key (S3_* and CARACMS_CLOUD_KEY); prints JSON.
40
43
  start Serve the built site (dist/server/entry.mjs).
41
44
  `;
42
45
 
package/bin/media.mjs CHANGED
@@ -20,22 +20,43 @@
20
20
  // - Credentials: `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID` and
21
21
  // `S3_SECRET_ACCESS_KEY`, from the environment, else the site's `.env` in the cwd.
22
22
  // - S3's SigV4 by hand, path-style requests (Railway's endpoint takes both styles).
23
+ // - **On CaraCMS Cloud** (caracms-cloud M3 Decision 6): with `CARACMS_CLOUD_KEY` (and
24
+ // `CARACMS_CLOUD_URL` for another deployment; both read as the `S3_*` are) it checks
25
+ // and uploads through the cloud's media API instead (the list, object and upload-URL
26
+ // routes), with the same rules, output and exit codes. The key with any of the site's
27
+ // own media variables is refused with the running site's sentence: one credential.
23
28
  //
24
29
  // Output: one JSON array on stdout, `[{ "file", "key", "w", "h", "existed" }]`, `file`
25
30
  // as given. Messages go to stderr. Exit codes: 0 fine, 1 failed, 2 bad usage.
31
+ //
32
+ // `caracms media push` (caracms-cloud M3 Decision 6a) moves a site's originals into the
33
+ // cloud, keeping every key: it reads the site's own bucket with `S3_*` and writes each
34
+ // `uploads/` object into the site's cloud folder through upload URLs with
35
+ // `CARACMS_CLOUD_KEY` (the one command that takes both). Each object's bytes are checked
36
+ // against the hash in its key, and a key not of the upload shape is skipped as failed;
37
+ // one already in the cloud is skipped, so it can be stopped and run again. Its metadata
38
+ // (`w`, `h`, `name`, `by`) is carried over. Nothing in the source bucket is changed.
39
+ // Output: `{ "copied", "skipped", "failed": [{ "key", "reason" }] }` (counts, and the
40
+ // failures), progress on stderr; exit 0 when nothing failed, 1 otherwise (the cloud's
41
+ // `upload_limit` stops the run there, with its sentence), 2 for bad usage.
26
42
 
27
43
  import { createHash, createHmac } from 'node:crypto';
28
44
  import { readFileSync } from 'node:fs';
29
45
  import { readFile } from 'node:fs/promises';
30
46
  import { basename, join, resolve } from 'node:path';
31
47
 
32
- const USAGE = 'Usage: caracms media import <files…>';
48
+ const USAGE = 'Usage: caracms media import <files…>\n caracms media push';
49
+
50
+ /** The production API's address (src/live/cloud.ts `DEFAULT_CLOUD_URL`; a test keeps the two equal). */
51
+ export const DEFAULT_CLOUD_URL = 'https://glad-bird-412.ap-southeast-2.convex.site';
33
52
 
34
53
  /** What the upload route takes (src/editor/types.ts: MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS). */
35
54
  export const MAX_BYTES = 40_000_000;
36
55
  export const MAX_PIXELS = 50_000_000;
37
56
 
38
57
  export const ENV_NAMES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'];
58
+ /** The site's own media variables, which the cloud key replaces (src/live/mode.ts `OWN_MEDIA_VARIABLES`). */
59
+ export const OWN_MEDIA_NAMES = [...ENV_NAMES, 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'];
39
60
 
40
61
  // ── The key rule (upload.ts keyName, slug.ts slugify) ───────────────────────────
41
62
 
@@ -458,28 +479,51 @@ export function parseDotenv(text) {
458
479
  return out;
459
480
  }
460
481
 
461
- /** The five `S3_*` values: the environment's, else `.env`'s in `cwd`. Names what's missing. */
462
- export function readS3Env(env, cwd) {
482
+ /** A variable's value: the environment's, else `.env`'s in `cwd` (read once, when first needed). */
483
+ function envReader(env, cwd) {
463
484
  let file = null;
464
- const fromFile = () => {
465
- if (file) return file;
466
- try {
467
- file = parseDotenv(readFileSync(join(cwd, '.env'), 'utf8'));
468
- } catch {
469
- file = {};
485
+ return (name) => {
486
+ if (env[name]) return env[name];
487
+ if (!file) {
488
+ try {
489
+ file = parseDotenv(readFileSync(join(cwd, '.env'), 'utf8'));
490
+ } catch {
491
+ file = {};
492
+ }
470
493
  }
471
- return file;
494
+ return file[name] || '';
472
495
  };
496
+ }
497
+
498
+ /** The five `S3_*` values: the environment's, else `.env`'s in `cwd`. Names what's missing. */
499
+ export function readS3Env(env, cwd) {
500
+ const get = envReader(env, cwd);
473
501
  const values = {};
474
502
  const missing = [];
475
503
  for (const name of ENV_NAMES) {
476
- const v = env[name] || fromFile()[name];
504
+ const v = get(name);
477
505
  if (v) values[name] = v;
478
506
  else missing.push(name);
479
507
  }
480
508
  return { values, missing };
481
509
  }
482
510
 
511
+ const listed = (names) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
512
+
513
+ /**
514
+ * The cloud key and address, as the `S3_*` are read, or null without a key; `own` names the
515
+ * site's own media variables also set (the running site's "not both", src/media.ts).
516
+ */
517
+ export function readCloudEnv(env, cwd) {
518
+ const get = envReader(env, cwd);
519
+ const key = get('CARACMS_CLOUD_KEY').trim();
520
+ if (!key) return null;
521
+ const url = (get('CARACMS_CLOUD_URL').trim() || DEFAULT_CLOUD_URL).replace(/\/+$/, '');
522
+ return { key, url, own: OWN_MEDIA_NAMES.filter((name) => get(name)) };
523
+ }
524
+
525
+ export const bothMessage = (names) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
526
+
483
527
  // ── S3 (SigV4 by hand, path style) ─────────────────────────────────────────────
484
528
 
485
529
  const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
@@ -515,10 +559,11 @@ export function signS3(creds, { method, key = '', query = {}, body, headers = {}
515
559
  }
516
560
 
517
561
  /** One signed request. Only GET (a listing), HEAD and PUT: nothing here deletes. */
518
- async function s3(creds, method, key, { query, body, headers } = {}) {
562
+ async function s3(creds, method, key, { query, body, headers, bytes = false } = {}) {
519
563
  if (method !== 'GET' && method !== 'HEAD' && method !== 'PUT') throw new Error(`No ${method} here.`);
520
564
  const signed = signS3(creds, { method, key, query, body, headers });
521
- const res = await fetch(signed.url, { method, headers: signed.headers, body: body ? new Uint8Array(body) : undefined, signal: AbortSignal.timeout(60_000) });
565
+ const res = await fetch(signed.url, { method, headers: signed.headers, body: body ? new Uint8Array(body) : undefined, signal: AbortSignal.timeout(bytes ? 120_000 : 60_000) });
566
+ if (bytes) return { res, text: '', bytes: res.ok ? Buffer.from(await res.arrayBuffer()) : (await res.body?.cancel().catch(() => {}), null) };
522
567
  const text = method === 'HEAD' ? '' : await res.text();
523
568
  return { res, text };
524
569
  }
@@ -533,6 +578,22 @@ async function first(creds, prefix) {
533
578
  return m ? unxml(m[1]) : null;
534
579
  }
535
580
 
581
+ /** Every key under a prefix, following S3's continuation tokens (`push`'s source). */
582
+ async function listAll(creds, prefix) {
583
+ const keys = [];
584
+ let token = null;
585
+ for (let n = 0; n < 1000; n++) {
586
+ const query = { 'list-type': '2', prefix, ...(token ? { 'continuation-token': token } : {}) };
587
+ const { res, text } = await s3(creds, 'GET', '', { query });
588
+ if (!res.ok) throw new Error(`listing ${prefix} failed: ${res.status} ${text.slice(0, 300)}`);
589
+ for (const m of text.matchAll(/<Contents>[\s\S]*?<Key>([\s\S]*?)<\/Key>/g)) keys.push(unxml(m[1]));
590
+ const next = /<IsTruncated>true<\/IsTruncated>/.test(text) ? /<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/.exec(text)?.[1] : null;
591
+ if (!next || next === token) break;
592
+ token = unxml(next);
593
+ }
594
+ return keys;
595
+ }
596
+
536
597
  /** An existing object's stored `w` and `h`, when it has them. */
537
598
  async function storedSize(creds, key) {
538
599
  const { res } = await s3(creds, 'HEAD', key);
@@ -543,6 +604,74 @@ async function storedSize(creds, key) {
543
604
  return w && h ? { w, h } : null;
544
605
  }
545
606
 
607
+ // ── CaraCMS Cloud's media API (the M3 spec's Contracts) ─────────────────────────
608
+
609
+ /** The cloud said no, with its sentence (`message`, shown as sent), or couldn't be reached (`error: 'unreachable'`). */
610
+ export class CloudRefused extends Error {
611
+ constructor(error, message) {
612
+ super(message);
613
+ this.error = error;
614
+ }
615
+ }
616
+
617
+ /** One call: the site key as the bearer, a JSON body for a POST, the answer's JSON or a CloudRefused. */
618
+ async function cloudApi(cloud, method, path, body) {
619
+ let res;
620
+ let answer;
621
+ try {
622
+ res = await fetch(`${cloud.url}${path}`, {
623
+ method,
624
+ headers: { authorization: `Bearer ${cloud.key}`, accept: 'application/json', 'user-agent': 'caracms', ...(body ? { 'content-type': 'application/json' } : {}) },
625
+ body: body ? JSON.stringify(body) : undefined,
626
+ signal: AbortSignal.timeout(60_000),
627
+ });
628
+ answer = await res.json();
629
+ } catch {
630
+ throw new CloudRefused('unreachable', "CaraCMS Cloud couldn't be reached. Try again in a minute.");
631
+ }
632
+ if (!answer || typeof answer !== 'object') throw new CloudRefused('unreachable', "CaraCMS Cloud couldn't be reached. Try again in a minute.");
633
+ if (!res.ok) {
634
+ if (typeof answer.error !== 'string' || typeof answer.message !== 'string') throw new CloudRefused('unreachable', `CaraCMS Cloud answered ${res.status}.`);
635
+ throw new CloudRefused(answer.error, answer.message);
636
+ }
637
+ return answer;
638
+ }
639
+
640
+ /** The first key under a prefix in the site's cloud folder. */
641
+ async function cloudFirst(cloud, prefix) {
642
+ const answer = await cloudApi(cloud, 'GET', `/v1/media/list?${new URLSearchParams({ prefix })}`);
643
+ return Array.isArray(answer.objects) && typeof answer.objects[0]?.key === 'string' ? answer.objects[0].key : null;
644
+ }
645
+
646
+ /** An object's details in the cloud, or null for `not_found`. */
647
+ async function cloudObject(cloud, key) {
648
+ try {
649
+ return await cloudApi(cloud, 'GET', `/v1/media/object?${new URLSearchParams({ key })}`);
650
+ } catch (error) {
651
+ if (error instanceof CloudRefused && error.error === 'not_found') return null;
652
+ throw error;
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Write a new object: an upload URL for exactly these bytes, then the PUT with the headers it
658
+ * names (Content-Length is fetch's own). Returns the storage's status: 200s are stored, 412
659
+ * means the key is already there.
660
+ */
661
+ async function cloudPut(cloud, { key, body, contentType, meta }) {
662
+ const answer = await cloudApi(cloud, 'POST', '/v1/media/upload-url', { key, size: body.byteLength, contentType, sha256: sha256hex(body), meta });
663
+ if (typeof answer.url !== 'string' || !answer.headers || typeof answer.headers !== 'object') throw new CloudRefused('unreachable', 'CaraCMS Cloud gave no upload URL.');
664
+ const headers = Object.fromEntries(Object.entries(answer.headers).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)));
665
+ let res;
666
+ try {
667
+ res = await fetch(answer.url, { method: 'PUT', headers, body: new Uint8Array(body), signal: AbortSignal.timeout(120_000) });
668
+ } catch {
669
+ throw new CloudRefused('unreachable', "CaraCMS Cloud's storage couldn't be reached. Try again in a minute.");
670
+ }
671
+ await res.body?.cancel().catch(() => {});
672
+ return res.status;
673
+ }
674
+
546
675
  // ── The command ────────────────────────────────────────────────────────────────
547
676
 
548
677
  /**
@@ -561,6 +690,15 @@ export async function run(argv, io = {}) {
561
690
  out.write(`${USAGE}\n`);
562
691
  return 0;
563
692
  }
693
+ if (sub === 'push') {
694
+ const extra = rest.filter((a) => a !== '--');
695
+ if (extra.length > 0) {
696
+ say(extra[0].startsWith('-') ? `Unknown option: ${extra[0]}` : 'media push takes no arguments: it copies everything under uploads/.');
697
+ say(USAGE);
698
+ return 2;
699
+ }
700
+ return push({ env, cwd, out, say });
701
+ }
564
702
  if (sub !== 'import') {
565
703
  say(sub ? `Unknown media command: ${sub}` : 'Which media command?');
566
704
  say(USAGE);
@@ -579,11 +717,21 @@ export async function run(argv, io = {}) {
579
717
  return 2;
580
718
  }
581
719
 
582
- const { values: creds, missing } = readS3Env(env, cwd);
583
- if (missing.length > 0) {
584
- say(`Missing ${missing.join(', ')}: set ${missing.length === 1 ? 'it' : 'them'} in the environment or in this site's .env.`);
720
+ // The cloud key, when it's set, is the one credential (caracms-cloud M3 Decisions 6 and 19).
721
+ const cloud = readCloudEnv(env, cwd);
722
+ if (cloud && cloud.own.length > 0) {
723
+ say(bothMessage(cloud.own));
585
724
  return 1;
586
725
  }
726
+ let creds = null;
727
+ if (!cloud) {
728
+ const { values, missing } = readS3Env(env, cwd);
729
+ if (missing.length > 0) {
730
+ say(`Missing ${missing.join(', ')}: set ${missing.length === 1 ? 'it' : 'them'} in the environment or in this site's .env (or set CARACMS_CLOUD_KEY for a site on CaraCMS Cloud).`);
731
+ return 1;
732
+ }
733
+ creds = values;
734
+ }
587
735
 
588
736
  // Every file read and checked before anything is written.
589
737
  const items = [];
@@ -630,33 +778,163 @@ export async function run(argv, io = {}) {
630
778
  try {
631
779
  for (const item of items) {
632
780
  const { file, bytes, key, hash, sniffed } = item;
633
- const existing = await first(creds, `uploads/${hash}/`);
781
+ const existing = cloud ? await cloudFirst(cloud, `uploads/${hash}/`) : await first(creds, `uploads/${hash}/`);
634
782
  if (existing) {
635
- const size = (await storedSize(creds, existing)) ?? { w: sniffed.w, h: sniffed.h };
783
+ const size = (cloud ? sizeOf(await cloudObject(cloud, existing)) : await storedSize(creds, existing)) ?? { w: sniffed.w, h: sniffed.h };
636
784
  results.push({ file, key: existing, w: size.w, h: size.h, existed: true });
637
785
  say(`${file}: already there, ${existing}`);
638
786
  continue;
639
787
  }
640
788
  const name = cleanName(basename(file)) || key.slice(key.lastIndexOf('/') + 1);
641
- const { res, text } = await s3(creds, 'PUT', key, {
642
- body: bytes,
643
- headers: {
644
- 'content-type': sniffed.mime,
645
- 'x-amz-meta-w': String(sniffed.w),
646
- 'x-amz-meta-h': String(sniffed.h),
647
- 'x-amz-meta-name': encodeURIComponent(name),
648
- 'x-amz-meta-by': 'import',
649
- },
650
- });
651
- if (!res.ok) throw new Error(`writing ${key} failed: ${res.status} ${text.slice(0, 300)}`);
789
+ if (cloud) {
790
+ const status = await cloudPut(cloud, { key, body: bytes, contentType: sniffed.mime, meta: { w: sniffed.w, h: sniffed.h, name, by: 'import' } });
791
+ // 412: the key (and so these bytes) is already there; it went in between the check and the write.
792
+ if (status === 412) {
793
+ results.push({ file, key, w: sniffed.w, h: sniffed.h, existed: true });
794
+ say(`${file}: already there, ${key}`);
795
+ continue;
796
+ }
797
+ if (status < 200 || status >= 300) throw new Error(`writing ${key} failed: the storage answered ${status}`);
798
+ } else {
799
+ const { res, text } = await s3(creds, 'PUT', key, {
800
+ body: bytes,
801
+ headers: {
802
+ 'content-type': sniffed.mime,
803
+ 'x-amz-meta-w': String(sniffed.w),
804
+ 'x-amz-meta-h': String(sniffed.h),
805
+ 'x-amz-meta-name': encodeURIComponent(name),
806
+ 'x-amz-meta-by': 'import',
807
+ },
808
+ });
809
+ if (!res.ok) throw new Error(`writing ${key} failed: ${res.status} ${text.slice(0, 300)}`);
810
+ }
652
811
  results.push({ file, key, w: sniffed.w, h: sniffed.h, existed: false });
653
812
  say(`${file}: ${key} (${sniffed.w}×${sniffed.h})`);
654
813
  }
655
814
  } catch (error) {
656
- say(`The bucket refused or didn't answer: ${error?.message ?? error}`);
815
+ // The cloud's own sentence as sent (a full site, a revoked key); anything else as before.
816
+ say(error instanceof CloudRefused ? error.message : `The bucket refused or didn't answer: ${error?.message ?? error}`);
657
817
  if (results.length > 0) say(`These went in before it stopped: ${JSON.stringify(results)}`);
658
818
  return 1;
659
819
  }
660
820
  out.write(`${JSON.stringify(results, null, 2)}\n`);
661
821
  return 0;
662
822
  }
823
+
824
+ /** A cloud object's `w` and `h`, when it has them. */
825
+ const sizeOf = (object) => (object && Number.isInteger(object.w) && object.w > 0 && Number.isInteger(object.h) && object.h > 0 ? { w: object.w, h: object.h } : null);
826
+
827
+ // ── media push (caracms-cloud M3 Decision 6a) ──────────────────────────────────
828
+
829
+ /** The upload route's key shape (the M3 spec's Decision 15): the hash, the slugified name, an extension the library takes. */
830
+ const UPLOAD_KEY = /^uploads\/([0-9a-f]{12})\/([\p{Ll}\p{Lo}\p{N}]+(?:-[\p{Ll}\p{Lo}\p{N}]+)*)\.(jpg|png|webp|avif|heic|heif|gif|svg|pdf|docx|doc|xlsx|xls|pptx|ppt|csv|zip)$/u;
831
+ /** A type for an object whose source has none (src/editor/server/media/sniff.ts and src/files.ts name the same). */
832
+ const TYPES = {
833
+ jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif', gif: 'image/gif', svg: 'image/svg+xml',
834
+ pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', doc: 'application/msword',
835
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', xls: 'application/vnd.ms-excel',
836
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ppt: 'application/vnd.ms-powerpoint', csv: 'text/csv', zip: 'application/zip',
837
+ };
838
+ /** Refusals about one key: report it and carry on. Any other (the cap, a revoked key, the storage away) stops the run. */
839
+ const PER_KEY = new Set(['bad_key', 'bad_request', 'too_large', 'exists']);
840
+
841
+ const decoded = (v) => {
842
+ if (!v) return null;
843
+ try {
844
+ return decodeURIComponent(v);
845
+ } catch {
846
+ return v;
847
+ }
848
+ };
849
+ const metaSide = (v) => (v != null && /^\d+$/.test(v) && Number(v) > 0 ? Number(v) : undefined);
850
+
851
+ async function push({ env, cwd, out, say }) {
852
+ const cloud = readCloudEnv(env, cwd);
853
+ const { values: creds, missing } = readS3Env(env, cwd);
854
+ if (!cloud || missing.length > 0) {
855
+ const need = [...(cloud ? [] : ['CARACMS_CLOUD_KEY']), ...missing];
856
+ say(`Missing ${listed(need)}: media push reads the site's own bucket (S3_*) and writes to its CaraCMS Cloud folder (CARACMS_CLOUD_KEY). Set ${need.length === 1 ? 'it' : 'them'} in the environment or in this site's .env.`);
857
+ return 1;
858
+ }
859
+
860
+ let copied = 0;
861
+ let skipped = 0;
862
+ const failed = [];
863
+ const done = (code) => {
864
+ out.write(`${JSON.stringify({ copied, skipped, failed }, null, 2)}\n`);
865
+ return code;
866
+ };
867
+
868
+ let keys;
869
+ try {
870
+ keys = (await listAll(creds, 'uploads/')).sort();
871
+ } catch (error) {
872
+ say(`The site's bucket refused or didn't answer: ${error?.message ?? error}`);
873
+ return 1;
874
+ }
875
+ say(`${keys.length} object${keys.length === 1 ? '' : 's'} under uploads/ in ${creds.S3_BUCKET}.`);
876
+
877
+ for (const key of keys) {
878
+ const shape = UPLOAD_KEY.exec(key);
879
+ if (!shape) {
880
+ failed.push({ key, reason: "It isn't of the upload key's shape (uploads/<12 hex>/<name>.<ext>, an extension the library takes)." });
881
+ say(`failed ${key}: not of the upload key's shape`);
882
+ continue;
883
+ }
884
+ try {
885
+ if (await cloudObject(cloud, key)) {
886
+ skipped++;
887
+ say(`skipped ${key} (already in the cloud)`);
888
+ continue;
889
+ }
890
+ const { res, bytes } = await s3(creds, 'GET', key, { bytes: true });
891
+ if (!bytes) {
892
+ failed.push({ key, reason: `The site's bucket answered ${res.status} for it.` });
893
+ say(`failed ${key}: the site's bucket answered ${res.status}`);
894
+ continue;
895
+ }
896
+ if (bytes.byteLength > MAX_BYTES) {
897
+ failed.push({ key, reason: `It's ${(bytes.byteLength / 1e6).toFixed(1)} MB; the library takes up to ${MAX_BYTES / 1e6} MB.` });
898
+ say(`failed ${key}: over ${MAX_BYTES / 1e6} MB`);
899
+ continue;
900
+ }
901
+ if (sha256hex(bytes).slice(0, 12) !== shape[1]) {
902
+ failed.push({ key, reason: "Its bytes don't match the hash in its key, so it wasn't sent." });
903
+ say(`failed ${key}: its bytes don't match the hash in its key`);
904
+ continue;
905
+ }
906
+ const h = res.headers;
907
+ const meta = {
908
+ ...(metaSide(h.get('x-amz-meta-w')) ? { w: metaSide(h.get('x-amz-meta-w')) } : {}),
909
+ ...(metaSide(h.get('x-amz-meta-h')) ? { h: metaSide(h.get('x-amz-meta-h')) } : {}),
910
+ name: decoded(h.get('x-amz-meta-name')) || key.slice(key.lastIndexOf('/') + 1),
911
+ by: decoded(h.get('x-amz-meta-by')),
912
+ };
913
+ const contentType = h.get('content-type') && h.get('content-type') !== 'application/octet-stream' ? h.get('content-type') : TYPES[shape[3]];
914
+ const status = await cloudPut(cloud, { key, body: bytes, contentType, meta });
915
+ if (status === 412) {
916
+ skipped++;
917
+ say(`skipped ${key} (already in the cloud)`);
918
+ } else if (status >= 200 && status < 300) {
919
+ copied++;
920
+ say(`copied ${key}`);
921
+ } else {
922
+ failed.push({ key, reason: `The cloud's storage answered ${status}.` });
923
+ say(`failed ${key}: the cloud's storage answered ${status}`);
924
+ }
925
+ } catch (error) {
926
+ if (error instanceof CloudRefused && PER_KEY.has(error.error)) {
927
+ failed.push({ key, reason: error.message });
928
+ say(`failed ${key}: ${error.message}`);
929
+ continue;
930
+ }
931
+ // The cap (upload_limit), a key that stopped working, the cloud or its storage away: stop here.
932
+ const reason = error instanceof CloudRefused ? error.message : `The site's bucket didn't answer: ${error?.message ?? error}`;
933
+ failed.push({ key, reason });
934
+ say(`Stopped at ${key}, after copying ${copied}: ${reason}`);
935
+ return done(1);
936
+ }
937
+ }
938
+ say(`Done: ${copied} copied, ${skipped} already there, ${failed.length} failed.`);
939
+ return done(failed.length === 0 ? 0 : 1);
940
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",