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 +126 -16
- 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 +85 -0
- package/src/editor/README.md +2 -1
- package/src/editor/server/media/cloud-bucket.ts +144 -0
- package/src/editor/server/media/cloud-mux.ts +110 -0
- package/src/editor/server/media/config.ts +30 -3
- package/src/editor/server/media/handler.ts +13 -1
- package/src/files-route.ts +2 -1
- package/src/forms/cloud-store.ts +212 -0
- package/src/forms/email.ts +52 -2
- package/src/forms/endpoint.ts +17 -5
- package/src/forms/store.ts +38 -3
- package/src/index.ts +10 -1
- package/src/live/README.md +1 -1
- package/src/live/auth/email.ts +22 -0
- 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 +44 -1
- package/src/live/runtime.ts +15 -2
- package/src/media.ts +50 -2
|
@@ -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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings fetch for a cloud site's media (caracms-cloud M3 Decision 20). The package
|
|
3
|
+
* has no boot hook in the built server, so **the first request that reaches the server, of
|
|
4
|
+
* any kind,** fetches `GET /v1/settings` (live/cloud.ts → `loadCloudSettings`) and every
|
|
5
|
+
* request waits on that one shared fetch before anything renders. The live runtime reads the
|
|
6
|
+
* same held answer, so it's one fetch per process. Prerendered pages are files and never wait.
|
|
7
|
+
*
|
|
8
|
+
* - Registered first (`addMiddleware({ order: 'pre' })` in index.ts), before the editor's.
|
|
9
|
+
* - Only for a cloud site's media: `CARACMS_CLOUD_KEY` set, `CARACMS_MEDIA` not `memory`,
|
|
10
|
+
* and none of the site's own media variables (that's `readMediaConfig`'s problem sentence).
|
|
11
|
+
* - A fetch that fails leaves the media unset (v3's handling: the editor's media routes 503
|
|
12
|
+
* with the cloud's sentence) and is tried again by a request after `RETRY_MS`, so a cloud
|
|
13
|
+
* that's away doesn't hold every request for its timeout.
|
|
14
|
+
*
|
|
15
|
+
* `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
|
|
16
|
+
* a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
|
|
17
|
+
* package's own. The build ignores `github.repo`: a site with no repo linked still builds.
|
|
18
|
+
* The key beside the site's own forms and email variables fails it too (caracms-cloud M4
|
|
19
|
+
* Decision 22), whether or not the media is the cloud's.
|
|
20
|
+
*/
|
|
21
|
+
import type { MiddlewareHandler } from 'astro';
|
|
22
|
+
import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
|
|
23
|
+
import { cloudFormsConflict } from './live/mode.ts';
|
|
24
|
+
import { cloudMedia } from './media.ts';
|
|
25
|
+
|
|
26
|
+
type Env = Record<string, string | undefined>;
|
|
27
|
+
|
|
28
|
+
/** After a failed fetch, requests in the next half minute don't wait on another. */
|
|
29
|
+
export const RETRY_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
const NEXT_TRY = Symbol.for('caracms.cloudSettingsNextTry');
|
|
32
|
+
const LOGGED = Symbol.for('caracms.cloudSettingsLogged');
|
|
33
|
+
type Shared = { [NEXT_TRY]?: number; [LOGGED]?: string };
|
|
34
|
+
|
|
35
|
+
/** Fetch the settings if this process doesn't hold them yet (and isn't waiting out a failure). */
|
|
36
|
+
export async function ensureCloudSettings(env: Env = process.env, now = Date.now(), log: (line: string) => void = (line) => console.error(line)): Promise<void> {
|
|
37
|
+
const cloud = cloudMedia(env);
|
|
38
|
+
if (!cloud || cloud.problems.length) return;
|
|
39
|
+
if (heldCloudSettings(cloud.connection)?.kind === 'ready') return;
|
|
40
|
+
const g = globalThis as Shared;
|
|
41
|
+
if ((g[NEXT_TRY] ?? 0) > now) return;
|
|
42
|
+
try {
|
|
43
|
+
await loadCloudSettings(cloud.connection);
|
|
44
|
+
delete g[NEXT_TRY];
|
|
45
|
+
delete g[LOGGED];
|
|
46
|
+
} catch (error) {
|
|
47
|
+
g[NEXT_TRY] = now + RETRY_MS;
|
|
48
|
+
const line = isCloudRefusal(error) ? `CaraCMS Cloud (${error.error}): ${error.message}` : 'CaraCMS Cloud: the settings could not be read';
|
|
49
|
+
if (g[LOGGED] !== line) log(`[caracms] the media settings could not be fetched: ${line}`);
|
|
50
|
+
g[LOGGED] = line;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Forget the retry clock (tests). */
|
|
55
|
+
export function resetCloudSettingsRetry(): void {
|
|
56
|
+
const g = globalThis as Shared;
|
|
57
|
+
delete g[NEXT_TRY];
|
|
58
|
+
delete g[LOGGED];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const onRequest: MiddlewareHandler = async (_context, next) => {
|
|
62
|
+
await ensureCloudSettings();
|
|
63
|
+
return next();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
|
|
67
|
+
export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
|
|
68
|
+
// caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
|
|
69
|
+
const conflict = cloudFormsConflict(env);
|
|
70
|
+
const forms = conflict ? [conflict] : [];
|
|
71
|
+
const cloud = cloudMedia(env);
|
|
72
|
+
if (!cloud) {
|
|
73
|
+
if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
|
|
77
|
+
let settings;
|
|
78
|
+
try {
|
|
79
|
+
settings = await loadCloudSettings(cloud.connection);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
|
|
82
|
+
throw new Error(`caracms: ${message}`);
|
|
83
|
+
}
|
|
84
|
+
if (!settings.media) throw new Error(`caracms: ${CLOUD_NO_MEDIA}`);
|
|
85
|
+
}
|
package/src/editor/README.md
CHANGED
|
@@ -29,7 +29,8 @@ and an alt already used), `GET thumbnail?key=&width=` (a 302 to the signed URL),
|
|
|
29
29
|
`POST video-upload`, `GET video-upload/<id>`, `GET clips`, `GET clips/<id>`, `GET playback/<id>`.
|
|
30
30
|
Every shape is in `types.ts` → `MEDIA_ENDPOINTS`. `server/media/`: `sniff.ts` (the type and size
|
|
31
31
|
from the bytes), `upload.ts` (the 40 MB cap, the key rule, no overwrite), `bucket.ts` (S3 through
|
|
32
|
-
aws4fetch, and the in-memory bucket), `
|
|
32
|
+
aws4fetch, and the in-memory bucket), `cloud-bucket.ts` (the same seam on CaraCMS Cloud's media
|
|
33
|
+
API, `CARACMS_CLOUD_KEY`), `library.ts` (the 60 s listing, metadata per key,
|
|
33
34
|
where-used), `mux.ts` (the template's Mux rules), `alt.ts` (`WaitingView.missingAlt`),
|
|
34
35
|
`config.ts` (503s, images and clips apart) and `memory.ts` (`CARACMS_MEDIA=memory`: `memoryMedia()`
|
|
35
36
|
to seed the bucket and the fake Mux in tests).
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media bucket on CaraCMS Cloud (caracms-cloud M3 Decision 21), behind bucket.ts's
|
|
3
|
+
* seam, so the upload route (checks, key, dedupe, metadata) and the files route don't
|
|
4
|
+
* change. The site's server never holds R2 credentials: it asks the API (live/cloud.ts,
|
|
5
|
+
* the site key) and the API answers for the site's own folder. Keys stay `uploads/…`:
|
|
6
|
+
* the cloud adds `sites/<folder>/` and strips it again.
|
|
7
|
+
*
|
|
8
|
+
* - `list` and `first`: `GET /v1/media/list`, following its `next` tokens (`list`) or one page (`first`).
|
|
9
|
+
* - `head`: `GET /v1/media/object`; `404 not_found` is "no such object".
|
|
10
|
+
* - `get`: `POST /v1/media/download-url`, then the presigned GET, streamed (the files route).
|
|
11
|
+
* - `put`: `POST /v1/media/upload-url`, then the presigned PUT with exactly the headers it names.
|
|
12
|
+
*
|
|
13
|
+
* **The cloud's refusals are shown as sent** (`CloudRefusal`: `upload_limit`'s sentence,
|
|
14
|
+
* `storage_unavailable`'s); the media handler puts the message in its 502. A cloud that
|
|
15
|
+
* can't be reached, and the storage's own answers to the presigned URLs, are BucketErrors
|
|
16
|
+
* (the handler's own sentences). Presigned URLs go nowhere but the fetch here: never in
|
|
17
|
+
* a log line, an error or an answer to the browser.
|
|
18
|
+
*/
|
|
19
|
+
import { isCloudRefusal, cloudCall, type CloudConnection } from '../../../live/cloud.ts';
|
|
20
|
+
import { BucketError, type Bucket, type BucketObject, type ObjectMeta } from './bucket.ts';
|
|
21
|
+
|
|
22
|
+
const STORAGE_TIMEOUT_MS = 20_000;
|
|
23
|
+
/** A runaway-token guard, as s3Bucket's: 1000 pages of 1000 is far past one site's library. */
|
|
24
|
+
const MAX_PAGES = 1000;
|
|
25
|
+
|
|
26
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null);
|
|
27
|
+
const text = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
|
28
|
+
const iso = (v: unknown) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? new Date(v).toISOString() : null);
|
|
29
|
+
|
|
30
|
+
export function cloudBucket(connection: Pick<CloudConnection, 'key' | 'url'>, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args)): Bucket {
|
|
31
|
+
const conn: CloudConnection = { ...connection, fetch: fetchImpl };
|
|
32
|
+
|
|
33
|
+
/** One API call; a cloud that can't be reached is a BucketError, any other refusal goes up as sent. */
|
|
34
|
+
async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
35
|
+
try {
|
|
36
|
+
return await cloudCall(conn, method, path, body);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (isCloudRefusal(error) && error.error === 'unreachable') throw new BucketError(`${method} ${path.split('?')[0]}: CaraCMS Cloud unreachable`);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A presigned URL's request. The timeout covers the wait for the storage's headers (a `get` streams after). */
|
|
44
|
+
async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => controller.abort(new Error(`no answer in ${STORAGE_TIMEOUT_MS / 1000} s`)), STORAGE_TIMEOUT_MS);
|
|
47
|
+
try {
|
|
48
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new BucketError(`${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
|
|
51
|
+
} finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function page(prefix: string, after: string | null): Promise<{ objects: BucketObject[]; next: string | null }> {
|
|
57
|
+
const query = new URLSearchParams({ prefix });
|
|
58
|
+
if (after) query.set('after', after);
|
|
59
|
+
const answer = await api('GET', `/v1/media/list?${query}`);
|
|
60
|
+
if (!Array.isArray(answer.objects)) throw new BucketError(`list ${prefix}: the answer has no objects`);
|
|
61
|
+
const objects: BucketObject[] = [];
|
|
62
|
+
for (const o of answer.objects as Record<string, unknown>[]) {
|
|
63
|
+
const key = o && typeof o === 'object' ? text(o.key) : null;
|
|
64
|
+
if (!key) continue;
|
|
65
|
+
objects.push({ key, lastModified: iso(o.lastModified), size: typeof o.size === 'number' && Number.isFinite(o.size) ? o.size : null });
|
|
66
|
+
}
|
|
67
|
+
return { objects, next: text(answer.next) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
async list(prefix) {
|
|
72
|
+
const out: BucketObject[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
let after: string | null = null;
|
|
75
|
+
for (let n = 0; n < MAX_PAGES; n++) {
|
|
76
|
+
const { objects, next } = await page(prefix, after);
|
|
77
|
+
out.push(...objects);
|
|
78
|
+
if (!next || seen.has(next)) break;
|
|
79
|
+
seen.add(next);
|
|
80
|
+
after = next;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
},
|
|
84
|
+
async first(prefix) {
|
|
85
|
+
const { objects } = await page(prefix, null);
|
|
86
|
+
return objects[0]?.key ?? null;
|
|
87
|
+
},
|
|
88
|
+
async head(key): Promise<ObjectMeta | null> {
|
|
89
|
+
let answer: Record<string, unknown>;
|
|
90
|
+
try {
|
|
91
|
+
answer = await api('GET', `/v1/media/object?${new URLSearchParams({ key })}`);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isCloudRefusal(error) && error.error === 'not_found') return null;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
w: num(answer.w),
|
|
98
|
+
h: num(answer.h),
|
|
99
|
+
name: text(answer.name),
|
|
100
|
+
by: text(answer.by),
|
|
101
|
+
contentType: text(answer.contentType),
|
|
102
|
+
lastModified: iso(answer.lastModified),
|
|
103
|
+
size: typeof answer.size === 'number' && Number.isFinite(answer.size) ? answer.size : null,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
async get(key) {
|
|
107
|
+
const { url } = await api('POST', '/v1/media/download-url', { key });
|
|
108
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) throw new BucketError(`get ${key}: the answer has no URL`);
|
|
109
|
+
const res = await storage(`get ${key}`, url, { method: 'GET' });
|
|
110
|
+
if (res.status === 404) {
|
|
111
|
+
await res.body?.cancel().catch(() => {});
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok || !res.body) {
|
|
115
|
+
await res.body?.cancel().catch(() => {});
|
|
116
|
+
throw new BucketError(`get ${key}: ${res.status}`, res.status);
|
|
117
|
+
}
|
|
118
|
+
const size = Number(res.headers.get('content-length'));
|
|
119
|
+
return { body: res.body, size: res.headers.has('content-length') && Number.isFinite(size) ? size : null };
|
|
120
|
+
},
|
|
121
|
+
async put({ key, body, contentType, sha256, meta }) {
|
|
122
|
+
const answer = await api('POST', '/v1/media/upload-url', {
|
|
123
|
+
key,
|
|
124
|
+
size: body.byteLength,
|
|
125
|
+
contentType,
|
|
126
|
+
sha256,
|
|
127
|
+
meta: { ...(meta.w !== undefined ? { w: meta.w } : {}), ...(meta.h !== undefined ? { h: meta.h } : {}), name: meta.name, by: meta.by },
|
|
128
|
+
});
|
|
129
|
+
const { url, method, headers } = answer;
|
|
130
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
|
|
131
|
+
throw new BucketError(`put ${key}: the upload answer isn't one`);
|
|
132
|
+
}
|
|
133
|
+
// The headers as the cloud named them, verbatim (the URL signs each one). Content-Length and Host
|
|
134
|
+
// are fetch's own to send: the body is exactly the declared size, and the URL's host is the signed one.
|
|
135
|
+
const sent = Object.fromEntries(
|
|
136
|
+
Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
|
|
137
|
+
) as Record<string, string>;
|
|
138
|
+
const res = await storage(`put ${key}`, url, { method: 'PUT', headers: sent, body: body as BodyInit });
|
|
139
|
+
await res.body?.cancel().catch(() => {});
|
|
140
|
+
// 412: R2 refused the signed If-None-Match: * because the key is there, and the key carries the bytes' hash: already stored.
|
|
141
|
+
if (!res.ok && res.status !== 412) throw new BucketError(`put ${key}: ${res.status}`, res.status);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mux on CaraCMS Cloud (caracms-cloud M4.5 Decision 16), behind mux.ts's seam, so the clip
|
|
3
|
+
* routes, where-used (`withUses`) and the editor don't change. The site's server holds no
|
|
4
|
+
* Mux token: it asks the API (live/cloud.ts, the site key), which asks the cloud's own
|
|
5
|
+
* Mux environment and answers from the site's own clips only. One route per method:
|
|
6
|
+
*
|
|
7
|
+
* - `createUpload`: `POST /v1/video/upload` `{ origin, title }` → `{ id, url }`.
|
|
8
|
+
* - `upload`: `GET /v1/video/upload?id=` → `VideoUploadStatus`.
|
|
9
|
+
* - `clip`: `GET /v1/video/clip?id=` → `Clip`; `clipOfPlayback`: `GET /v1/video/playback?id=`.
|
|
10
|
+
* - `clips`: `GET /v1/video/clips` → `{ clips }`, newest first (the cloud keeps the order).
|
|
11
|
+
*
|
|
12
|
+
* **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
|
|
13
|
+
* them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
|
|
14
|
+
* with the cloud's sentence as sent; any other refusal (`video_unavailable`, the key's own
|
|
15
|
+
* refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
|
|
16
|
+
* an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
|
|
17
|
+
* logged with the refusal's code. The key is never logged nor put in an error.
|
|
18
|
+
*/
|
|
19
|
+
import type { VideoUploadStatus } from '../../types.ts';
|
|
20
|
+
import { cloudCall, isCloudRefusal, type CloudConnection } from '../../../live/cloud.ts';
|
|
21
|
+
import { MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
|
|
22
|
+
|
|
23
|
+
export const CLOUD_VIDEO_UNREACHABLE = "CaraCMS Cloud couldn't be reached just now. Try again in a minute.";
|
|
24
|
+
export const CLOUD_VIDEO_UNREADABLE = 'CaraCMS Cloud sent an unreadable reply.';
|
|
25
|
+
|
|
26
|
+
const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
|
27
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
28
|
+
const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
|
|
29
|
+
|
|
30
|
+
export function cloudMux(
|
|
31
|
+
connection: Pick<CloudConnection, 'key' | 'url'>,
|
|
32
|
+
options: { fetch?: typeof fetch; log?: (line: string) => void } = {},
|
|
33
|
+
): MuxClient {
|
|
34
|
+
const conn: CloudConnection = { ...connection, fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)) };
|
|
35
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
36
|
+
|
|
37
|
+
async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
38
|
+
const where = `${method} ${path.split('?')[0]}`;
|
|
39
|
+
try {
|
|
40
|
+
return await cloudCall(conn, method, path, body);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!isCloudRefusal(error)) throw error;
|
|
43
|
+
if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
|
|
44
|
+
if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
|
|
45
|
+
log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
46
|
+
throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const unreadable = (where: string): never => {
|
|
51
|
+
log(`[caracms] video ${where}: CaraCMS Cloud sent an unreadable reply`);
|
|
52
|
+
throw new MuxRouteError(502, CLOUD_VIDEO_UNREADABLE);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The cloud's `Clip`, checked field by field (it passes `toClip`'s shape through). */
|
|
56
|
+
const clipOf = (raw: unknown, where: string): Clip => {
|
|
57
|
+
const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
|
|
58
|
+
const id = str(c?.id);
|
|
59
|
+
if (!c || !id) return unreadable(where);
|
|
60
|
+
const playbackId = str(c.playbackId);
|
|
61
|
+
const errored = c.errored === true;
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
playbackId: playbackId && MUX_ID.test(playbackId) ? playbackId : null,
|
|
65
|
+
status: str(c.status) ?? 'unknown',
|
|
66
|
+
ready: c.ready === true,
|
|
67
|
+
errored,
|
|
68
|
+
error: errored ? (str(c.error) ?? 'Mux could not process this file.') : null,
|
|
69
|
+
title: str(c.title),
|
|
70
|
+
duration: num(c.duration),
|
|
71
|
+
aspectRatio: str(c.aspectRatio),
|
|
72
|
+
w: px(c.w),
|
|
73
|
+
h: px(c.h),
|
|
74
|
+
createdAt: str(c.createdAt),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const query = (id: string) => new URLSearchParams({ id }).toString();
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async createUpload(origin, title) {
|
|
82
|
+
const answer = await api('POST', '/v1/video/upload', { origin, title });
|
|
83
|
+
const id = str(answer.id);
|
|
84
|
+
const url = str(answer.url);
|
|
85
|
+
if (!id || !url || !/^https:\/\//.test(url)) return unreadable('POST /v1/video/upload');
|
|
86
|
+
return { id, url };
|
|
87
|
+
},
|
|
88
|
+
async upload(id): Promise<VideoUploadStatus> {
|
|
89
|
+
const answer = await api('GET', `/v1/video/upload?${query(id)}`);
|
|
90
|
+
const assetId = str(answer.assetId);
|
|
91
|
+
return {
|
|
92
|
+
id: str(answer.id) ?? id,
|
|
93
|
+
status: str(answer.status) ?? 'unknown',
|
|
94
|
+
assetId: assetId && MUX_ID.test(assetId) ? assetId : null,
|
|
95
|
+
error: str(answer.error),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async clip(assetId) {
|
|
99
|
+
return clipOf(await api('GET', `/v1/video/clip?${query(assetId)}`), 'GET /v1/video/clip');
|
|
100
|
+
},
|
|
101
|
+
async clipOfPlayback(playbackId) {
|
|
102
|
+
return clipOf(await api('GET', `/v1/video/playback?${query(playbackId)}`), 'GET /v1/video/playback');
|
|
103
|
+
},
|
|
104
|
+
async clips() {
|
|
105
|
+
const answer = await api('GET', '/v1/video/clips');
|
|
106
|
+
if (!Array.isArray(answer.clips)) return unreadable('GET /v1/video/clips');
|
|
107
|
+
return answer.clips.map((c) => clipOf(c, 'GET /v1/video/clips'));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
* - Images need imgproxy's (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`, `IMGPROXY_SALT`, which
|
|
11
11
|
* sign the thumbnails) and the bucket's (`S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`,
|
|
12
12
|
* `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
|
|
13
|
+
* - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
|
|
14
|
+
* one credential: the bucket is the cloud's (cloud-bucket.ts), the signing from the
|
|
15
|
+
* settings the process holds (../../../media.ts).
|
|
16
|
+
* - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
|
|
17
|
+
* cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
|
|
18
|
+
* beside it is a problem sentence naming them (one credential, not two).
|
|
13
19
|
*/
|
|
14
|
-
import {
|
|
20
|
+
import type { CloudConnection } from '../../../live/cloud.ts';
|
|
21
|
+
import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
|
|
22
|
+
import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
15
23
|
|
|
16
24
|
type Env = Record<string, string | undefined>;
|
|
17
25
|
|
|
@@ -26,9 +34,14 @@ export interface BucketSettings {
|
|
|
26
34
|
export type ImagesConfig =
|
|
27
35
|
| { kind: 'memory'; signing: MediaSettings }
|
|
28
36
|
| { kind: 's3'; signing: MediaSettings; bucket: BucketSettings }
|
|
37
|
+
| { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
|
|
29
38
|
| { kind: 'unset'; message: string; missing: string[] };
|
|
30
39
|
|
|
31
|
-
export type MuxConfig =
|
|
40
|
+
export type MuxConfig =
|
|
41
|
+
| { kind: 'memory' }
|
|
42
|
+
| { kind: 'mux'; tokenId: string; tokenSecret: string }
|
|
43
|
+
| { kind: 'cloud'; connection: Pick<CloudConnection, 'key' | 'url'> }
|
|
44
|
+
| { kind: 'unset'; message: string; missing: string[] };
|
|
32
45
|
|
|
33
46
|
export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
|
|
34
47
|
|
|
@@ -48,6 +61,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
48
61
|
return { kind: 'memory', signing: signing.settings };
|
|
49
62
|
}
|
|
50
63
|
const signing = readMediaConfig(env, production);
|
|
64
|
+
const cloud = cloudMedia(env);
|
|
65
|
+
if (cloud) {
|
|
66
|
+
if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
|
|
67
|
+
return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
|
|
68
|
+
}
|
|
51
69
|
const missing = signing.kind === 'unset' ? [...signing.missing] : [];
|
|
52
70
|
const problems = signing.kind === 'unset' ? [...signing.problems] : [];
|
|
53
71
|
const need = (name: string) => {
|
|
@@ -67,9 +85,18 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
67
85
|
return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
/** The clip routes' settings: the site's Mux environment's token. */
|
|
88
|
+
/** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
|
|
71
89
|
export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
|
|
72
90
|
if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
91
|
+
const key = get(env, 'CARACMS_CLOUD_KEY');
|
|
92
|
+
if (key) {
|
|
93
|
+
const problems: string[] = [];
|
|
94
|
+
const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
|
|
95
|
+
if (both.length) problems.push(CLOUD_AND_MUX_PROBLEM(both));
|
|
96
|
+
const url = readCloudUrl(get(env, 'CARACMS_CLOUD_URL'), problems);
|
|
97
|
+
if (problems.length) return { kind: 'unset', message: problems.join(' '), missing: [] };
|
|
98
|
+
return { kind: 'cloud', connection: { key, url } };
|
|
99
|
+
}
|
|
73
100
|
const tokenId = get(env, 'MUX_TOKEN_ID');
|
|
74
101
|
const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
|
|
75
102
|
const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - **No lock** (Decision 7): an upload, or reading the library, changes no document.
|
|
11
11
|
* - **Every reply is `Cache-Control: private, no-store`.**
|
|
12
12
|
* - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
|
|
13
|
-
* imgproxy) and clips (Mux) apart: one missing never
|
|
13
|
+
* imgproxy) and clips (Mux, or CaraCMS Cloud's: cloud-mux.ts) apart: one missing never
|
|
14
|
+
* disables the other.
|
|
14
15
|
* - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
|
|
15
16
|
* only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
|
|
16
17
|
* - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
|
|
@@ -41,6 +42,9 @@ import type { DraftsStore } from '../drafts-store.ts';
|
|
|
41
42
|
import { parseReplaceBody } from '../replace.ts';
|
|
42
43
|
import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
|
|
43
44
|
import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
|
|
45
|
+
import { cloudBucket } from './cloud-bucket.ts';
|
|
46
|
+
import { cloudMux } from './cloud-mux.ts';
|
|
47
|
+
import { isCloudRefusal } from '../../../live/cloud.ts';
|
|
44
48
|
import { readImagesConfig, readMuxConfig } from './config.ts';
|
|
45
49
|
import { isFileKey } from '../../../files.ts';
|
|
46
50
|
import { entryOf, fileEntryOf, libraryFor, mediaUses, UPLOADS, type Library, type MediaUses } from './library.ts';
|
|
@@ -76,6 +80,7 @@ function images(ctx: MediaContext): { library: Library; signing: MediaSettings }
|
|
|
76
80
|
const config = readImagesConfig(ctx.env ?? process.env, ctx.production);
|
|
77
81
|
if (config.kind === 'unset') return json(503, { error: config.message, code: 'media-unset', missing: config.missing } satisfies MediaUnset);
|
|
78
82
|
if (config.kind === 'memory') return { library: libraryFor('memory', () => memoryMedia().bucket), signing: config.signing };
|
|
83
|
+
if (config.kind === 'cloud') return { library: libraryFor(`cloud:${config.connection.url}:${config.signing.prefix}`, (): Bucket => cloudBucket(config.connection, ctx.fetch)), signing: config.signing };
|
|
79
84
|
const b = config.bucket;
|
|
80
85
|
return { library: libraryFor(`s3:${b.endpoint}:${b.bucket}:${b.accessKeyId}`, (): Bucket => s3Bucket(b, ctx.fetch)), signing: config.signing };
|
|
81
86
|
}
|
|
@@ -85,6 +90,8 @@ function mux(ctx: MediaContext): MuxClient | Response {
|
|
|
85
90
|
if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
|
|
86
91
|
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
87
92
|
if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
|
|
93
|
+
// caracms-cloud M4.5: the cloud's Mux, through the site key; its refusals arrive as MuxRouteErrors.
|
|
94
|
+
if (config.kind === 'cloud') return cloudMux(config.connection, { fetch: ctx.fetch, log });
|
|
88
95
|
return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
|
|
89
96
|
}
|
|
90
97
|
|
|
@@ -210,6 +217,11 @@ export async function handleMedia(request: Request, ctx: MediaContext): Promise<
|
|
|
210
217
|
if (error instanceof UploadRefused) return json(error.status, { error: error.message, code: error.code } satisfies MediaRefusal);
|
|
211
218
|
if (error instanceof MuxRouteError) return json(error.status, { error: error.message } satisfies ApiError);
|
|
212
219
|
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
|
|
220
|
+
if (isCloudRefusal(error)) {
|
|
221
|
+
// caracms-cloud M3: the cloud's own sentence, as sent (a full site, its storage away).
|
|
222
|
+
log(`[caracms] media bucket: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
223
|
+
return json(502, { error: error.message } satisfies ApiError);
|
|
224
|
+
}
|
|
213
225
|
if (error instanceof BucketError) {
|
|
214
226
|
log(`[caracms] media bucket: ${error.message}`);
|
|
215
227
|
const said = error.status !== null && error.status >= 400 && error.status < 500;
|
package/src/files-route.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { APIRoute } from 'astro';
|
|
|
32
32
|
import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
|
|
33
33
|
import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
|
|
34
34
|
import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
|
|
35
|
+
import { cloudBucket } from './editor/server/media/cloud-bucket.ts';
|
|
35
36
|
import { readImagesConfig } from './editor/server/media/config.ts';
|
|
36
37
|
import { memoryMedia } from './editor/server/media/memory.ts';
|
|
37
38
|
import { isProductionBuild } from './media.ts';
|
|
@@ -168,7 +169,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
|
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
// 6. The one key the allow-list passed.
|
|
171
|
-
const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : s3Bucket(config.bucket, ctx.fetch);
|
|
172
|
+
const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : config.kind === 'cloud' ? cloudBucket(config.connection, ctx.fetch) : s3Bucket(config.bucket, ctx.fetch);
|
|
172
173
|
let object;
|
|
173
174
|
try {
|
|
174
175
|
object = await bucket.get(file.key);
|