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 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 and image variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App, `GITHUB_REPO` and the image variables: 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. |
@@ -151,7 +154,7 @@ 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` |
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 |
155
158
  | Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` |
156
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` |
157
160
  | Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above) |
@@ -327,11 +330,11 @@ 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).
331
335
  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.
336
+ `REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the video and form variables.
337
+ There's no webhook: a check once a minute keeps the editor's Publish status up to date.
335
338
 
336
339
  If the cloud says no when the server starts (the App isn't installed on the repo, the
337
340
  site has no repo yet, the key was revoked), `/admin` answers 503 with the cloud's own
@@ -340,10 +343,59 @@ says no later, while the editor is open, the editor shows the same sentence unti
340
343
  cloud gives a token again. A repo changed in CaraCMS Cloud reaches the site at its next
341
344
  start.
342
345
 
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.
346
+ #### Images on CaraCMS Cloud
347
+
348
+ With the key, the site's images live in its own folder of CaraCMS Cloud's media bucket and
349
+ are served, resized and signed, from `img.caracms.cloud`. Set none of `S3_ENDPOINT`,
350
+ `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `IMGPROXY_KEY`,
351
+ `IMGPROXY_SALT` or `CARACMS_MEDIA_URL`: setting any of them beside the key is refused with
352
+
353
+ > Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove S3_BUCKET.
354
+
355
+ The image host, the bucket, the site's folder and its signing key come from the cloud:
356
+ once when the site is built, and once when its server starts (on its first request). The
357
+ signing key is never in a variable, a log or anything sent to a browser. Uploads go to the
358
+ cloud through short-lived links the cloud makes for the site's server, and count towards
359
+ the site's storage limit; a full site's uploads are refused with the cloud's sentence. The
360
+ keys in content are the same `uploads/<hash>/<name>.<ext>` as anywhere else, so leaving
361
+ the cloud is copying the folder to a bucket of your own.
362
+
363
+ **A build needs the cloud.** If it says no (a revoked key, a team switched off), the build
364
+ fails with its sentence; if it can't be reached, with "CaraCMS Cloud couldn't be reached,
365
+ so the site can't be built. Try again in a minute." A site with no repo linked yet still
366
+ builds. A running site keeps serving whatever the cloud does.
367
+
368
+ `caracms media import <files…>` works with the key too (from the environment or the
369
+ site's `.env`), with the same rules and output, and its uploads count like any other.
370
+
371
+ #### Moving a site's images in
372
+
373
+ `caracms media push` copies everything under `uploads/` in the site's own bucket into its
374
+ CaraCMS Cloud folder, under the same keys, so the content doesn't change. It's the one
375
+ command that takes both credentials, so give the site's own `S3_*` on its command line
376
+ rather than in `.env` (the running site refuses both):
377
+
378
+ ```bash
379
+ S3_ENDPOINT=https://t3.storageapi.dev S3_REGION=auto S3_BUCKET=… S3_ACCESS_KEY_ID=… S3_SECRET_ACCESS_KEY=… npx caracms media push
380
+ ```
381
+
382
+ It checks each file's bytes against the hash in its key, skips anything already in the
383
+ cloud (so it can be stopped and run again), carries over each file's size, name and
384
+ uploader, and never changes the source bucket. It prints `{ copied, skipped, failed }`.
385
+ A site bigger than its storage limit stops at the limit with the cloud's sentence: ask
386
+ CaraCMS to raise it, then run it again.
387
+
388
+ #### Working locally
389
+
390
+ **Anyone working on a real site, wherever they are, uses that site's CaraCMS Cloud**: your
391
+ laptop, a colleague's, a client's developer, a staging deploy. Each gets **its own key**
392
+ for the site, labelled ("Rich's laptop", "staging") in app.caracms.com, so revoking one
393
+ never touches the live site. Leave `CARACMS_CLOUD_URL` unset: images render from
394
+ `img.caracms.cloud` and the library lists the site's real folder. A cloud key turns
395
+ `astro dev` into live mode on the site's real repo, as the App's variables do. Uploads
396
+ from a laptop land in the real folder and count towards its storage, which is harmless:
397
+ nothing is overwritten or deleted, and nothing is live until it's published.
398
+ `CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment.
347
399
 
348
400
  ### Behind Cloudflare
349
401
 
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.6.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -331,7 +331,9 @@ editor's uploads are) and prints one JSON array on stdout:
331
331
  ```
332
332
 
333
333
  - It reads `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID` and
334
- `S3_SECRET_ACCESS_KEY` from the environment, else the site's `.env`.
334
+ `S3_SECRET_ACCESS_KEY` from the environment, else the site's `.env`. On CaraCMS Cloud it
335
+ reads `CARACMS_CLOUD_KEY` instead (the same places) and uploads to the site's cloud
336
+ folder, with the same output; with both set it refuses.
335
337
  - The same image again writes nothing and prints its key with `"existed": true`. It never
336
338
  deletes anything.
337
339
  - JPEG, PNG, WebP, AVIF, HEIC/HEIF, GIF and SVG, up to 40 MB and 50 megapixels. If any file