caracms 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -11
- package/bin/caracms.mjs +5 -2
- package/bin/media.mjs +308 -30
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +3 -1
- package/src/cloud-middleware.ts +76 -0
- package/src/editor/README.md +2 -1
- package/src/editor/server/media/cloud-bucket.ts +144 -0
- package/src/editor/server/media/config.ts +11 -1
- package/src/editor/server/media/handler.ts +8 -0
- package/src/files-route.ts +2 -1
- package/src/index.ts +53 -4
- package/src/live/README.md +1 -1
- package/src/live/auth/middleware.ts +2 -2
- package/src/live/cloud.ts +151 -18
- package/src/live/github.ts +2 -2
- package/src/live/mode.ts +8 -1
- package/src/live/runtime.ts +15 -2
- package/src/media.ts +50 -2
package/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
|
|
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
|
|
333
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
-
/**
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 =
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
642
|
-
body: bytes,
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|