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
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
*/
|
|
19
|
+
import type { MiddlewareHandler } from 'astro';
|
|
20
|
+
import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
|
|
21
|
+
import { cloudMedia } from './media.ts';
|
|
22
|
+
|
|
23
|
+
type Env = Record<string, string | undefined>;
|
|
24
|
+
|
|
25
|
+
/** After a failed fetch, requests in the next half minute don't wait on another. */
|
|
26
|
+
export const RETRY_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
const NEXT_TRY = Symbol.for('caracms.cloudSettingsNextTry');
|
|
29
|
+
const LOGGED = Symbol.for('caracms.cloudSettingsLogged');
|
|
30
|
+
type Shared = { [NEXT_TRY]?: number; [LOGGED]?: string };
|
|
31
|
+
|
|
32
|
+
/** Fetch the settings if this process doesn't hold them yet (and isn't waiting out a failure). */
|
|
33
|
+
export async function ensureCloudSettings(env: Env = process.env, now = Date.now(), log: (line: string) => void = (line) => console.error(line)): Promise<void> {
|
|
34
|
+
const cloud = cloudMedia(env);
|
|
35
|
+
if (!cloud || cloud.problems.length) return;
|
|
36
|
+
if (heldCloudSettings(cloud.connection)?.kind === 'ready') return;
|
|
37
|
+
const g = globalThis as Shared;
|
|
38
|
+
if ((g[NEXT_TRY] ?? 0) > now) return;
|
|
39
|
+
try {
|
|
40
|
+
await loadCloudSettings(cloud.connection);
|
|
41
|
+
delete g[NEXT_TRY];
|
|
42
|
+
delete g[LOGGED];
|
|
43
|
+
} catch (error) {
|
|
44
|
+
g[NEXT_TRY] = now + RETRY_MS;
|
|
45
|
+
const line = isCloudRefusal(error) ? `CaraCMS Cloud (${error.error}): ${error.message}` : 'CaraCMS Cloud: the settings could not be read';
|
|
46
|
+
if (g[LOGGED] !== line) log(`[caracms] the media settings could not be fetched: ${line}`);
|
|
47
|
+
g[LOGGED] = line;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Forget the retry clock (tests). */
|
|
52
|
+
export function resetCloudSettingsRetry(): void {
|
|
53
|
+
const g = globalThis as Shared;
|
|
54
|
+
delete g[NEXT_TRY];
|
|
55
|
+
delete g[LOGGED];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const onRequest: MiddlewareHandler = async (_context, next) => {
|
|
59
|
+
await ensureCloudSettings();
|
|
60
|
+
return next();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
|
|
64
|
+
export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
|
|
65
|
+
const cloud = cloudMedia(env);
|
|
66
|
+
if (!cloud) return;
|
|
67
|
+
if (cloud.problems.length) throw new Error(`caracms: ${cloud.problems.join(' ')}`);
|
|
68
|
+
let settings;
|
|
69
|
+
try {
|
|
70
|
+
settings = await loadCloudSettings(cloud.connection);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
|
|
73
|
+
throw new Error(`caracms: ${message}`);
|
|
74
|
+
}
|
|
75
|
+
if (!settings.media) throw new Error(`caracms: ${CLOUD_NO_MEDIA}`);
|
|
76
|
+
}
|
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
|
+
}
|
|
@@ -10,8 +10,12 @@
|
|
|
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). Mux is unchanged.
|
|
13
16
|
*/
|
|
14
|
-
import {
|
|
17
|
+
import type { CloudConnection } from '../../../live/cloud.ts';
|
|
18
|
+
import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
15
19
|
|
|
16
20
|
type Env = Record<string, string | undefined>;
|
|
17
21
|
|
|
@@ -26,6 +30,7 @@ export interface BucketSettings {
|
|
|
26
30
|
export type ImagesConfig =
|
|
27
31
|
| { kind: 'memory'; signing: MediaSettings }
|
|
28
32
|
| { kind: 's3'; signing: MediaSettings; bucket: BucketSettings }
|
|
33
|
+
| { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
|
|
29
34
|
| { kind: 'unset'; message: string; missing: string[] };
|
|
30
35
|
|
|
31
36
|
export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
|
|
@@ -48,6 +53,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
48
53
|
return { kind: 'memory', signing: signing.settings };
|
|
49
54
|
}
|
|
50
55
|
const signing = readMediaConfig(env, production);
|
|
56
|
+
const cloud = cloudMedia(env);
|
|
57
|
+
if (cloud) {
|
|
58
|
+
if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
|
|
59
|
+
return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
|
|
60
|
+
}
|
|
51
61
|
const missing = signing.kind === 'unset' ? [...signing.missing] : [];
|
|
52
62
|
const problems = signing.kind === 'unset' ? [...signing.problems] : [];
|
|
53
63
|
const need = (name: string) => {
|
|
@@ -41,6 +41,8 @@ import type { DraftsStore } from '../drafts-store.ts';
|
|
|
41
41
|
import { parseReplaceBody } from '../replace.ts';
|
|
42
42
|
import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
|
|
43
43
|
import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
|
|
44
|
+
import { cloudBucket } from './cloud-bucket.ts';
|
|
45
|
+
import { isCloudRefusal } from '../../../live/cloud.ts';
|
|
44
46
|
import { readImagesConfig, readMuxConfig } from './config.ts';
|
|
45
47
|
import { isFileKey } from '../../../files.ts';
|
|
46
48
|
import { entryOf, fileEntryOf, libraryFor, mediaUses, UPLOADS, type Library, type MediaUses } from './library.ts';
|
|
@@ -76,6 +78,7 @@ function images(ctx: MediaContext): { library: Library; signing: MediaSettings }
|
|
|
76
78
|
const config = readImagesConfig(ctx.env ?? process.env, ctx.production);
|
|
77
79
|
if (config.kind === 'unset') return json(503, { error: config.message, code: 'media-unset', missing: config.missing } satisfies MediaUnset);
|
|
78
80
|
if (config.kind === 'memory') return { library: libraryFor('memory', () => memoryMedia().bucket), signing: config.signing };
|
|
81
|
+
if (config.kind === 'cloud') return { library: libraryFor(`cloud:${config.connection.url}:${config.signing.prefix}`, (): Bucket => cloudBucket(config.connection, ctx.fetch)), signing: config.signing };
|
|
79
82
|
const b = config.bucket;
|
|
80
83
|
return { library: libraryFor(`s3:${b.endpoint}:${b.bucket}:${b.accessKeyId}`, (): Bucket => s3Bucket(b, ctx.fetch)), signing: config.signing };
|
|
81
84
|
}
|
|
@@ -210,6 +213,11 @@ export async function handleMedia(request: Request, ctx: MediaContext): Promise<
|
|
|
210
213
|
if (error instanceof UploadRefused) return json(error.status, { error: error.message, code: error.code } satisfies MediaRefusal);
|
|
211
214
|
if (error instanceof MuxRouteError) return json(error.status, { error: error.message } satisfies ApiError);
|
|
212
215
|
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
|
|
216
|
+
if (isCloudRefusal(error)) {
|
|
217
|
+
// caracms-cloud M3: the cloud's own sentence, as sent (a full site, its storage away).
|
|
218
|
+
log(`[caracms] media bucket: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
219
|
+
return json(502, { error: error.message } satisfies ApiError);
|
|
220
|
+
}
|
|
213
221
|
if (error instanceof BucketError) {
|
|
214
222
|
log(`[caracms] media bucket: ${error.message}`);
|
|
215
223
|
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);
|
package/src/index.ts
CHANGED
|
@@ -46,12 +46,13 @@
|
|
|
46
46
|
* (Decision 5) and has Vite pre-bundle the editor's browser deps. The public pages
|
|
47
47
|
* have no script, no React and no editor markers in either.
|
|
48
48
|
*/
|
|
49
|
+
import { cloudSettingsForBuild } from './cloud-middleware.ts';
|
|
49
50
|
import type { AstroIntegration } from 'astro';
|
|
50
51
|
import { fontProviders } from 'astro/config';
|
|
51
52
|
import { execFileSync } from 'node:child_process';
|
|
52
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { existsSync, readFileSync, watch, writeFileSync, type FSWatcher } from 'node:fs';
|
|
53
54
|
import { createRequire } from 'node:module';
|
|
54
|
-
import { isAbsolute, join, sep } from 'node:path';
|
|
55
|
+
import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
55
56
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
56
57
|
import { resolveSite, SITE_SETTINGS_FILE, type ResolvedSite, type SiteConfig } from './config.ts';
|
|
57
58
|
import { lockupCss } from './lockup-css.ts';
|
|
@@ -198,6 +199,8 @@ export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
|
|
|
198
199
|
|
|
199
200
|
/** M4 Phase 2: the unconfigured server's 503, registered before Phase 3's guard. */
|
|
200
201
|
export const EDITOR_MIDDLEWARE = 'editor/middleware.ts';
|
|
202
|
+
/** caracms-cloud M3 Decision 20: a cloud site's settings, fetched by the first request (cloud-middleware.ts). */
|
|
203
|
+
export const CLOUD_MIDDLEWARE = 'cloud-middleware.ts';
|
|
201
204
|
|
|
202
205
|
/**
|
|
203
206
|
* Content files out of Vite's watch (Decision 5): a write never reloads `/admin`
|
|
@@ -219,6 +222,39 @@ export function contentWatchIgnores(site: Pick<ResolvedSite, 'collections' | 'ro
|
|
|
219
222
|
];
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The content contentWatchIgnores takes out of Vite's watch, watched apart, so the dev server
|
|
227
|
+
* learns a page was added, moved, hidden or removed. Astro's dev server caches each route's
|
|
228
|
+
* `getStaticPaths` and clears it only on its own content changes, so without this the public
|
|
229
|
+
* site kept its old list of pages until a restart (an old address even answered 500 rather
|
|
230
|
+
* than its redirect). Every collection folder that exists at start, recursively, and the
|
|
231
|
+
* Site Settings file; `onChange` once per burst of changes. Returns the stop.
|
|
232
|
+
*/
|
|
233
|
+
export function watchContent(site: Pick<ResolvedSite, 'collections' | 'root'>, onChange: () => void, wait = 100): () => void {
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
const changed = () => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
timer = setTimeout(onChange, wait);
|
|
238
|
+
};
|
|
239
|
+
const watchers: FSWatcher[] = [];
|
|
240
|
+
const add = (path: string, recursive: boolean, only?: string) => {
|
|
241
|
+
if (!existsSync(path)) return;
|
|
242
|
+
const watcher = watch(path, { recursive, persistent: false }, (_event, name) => {
|
|
243
|
+
if (!only || name === only) changed();
|
|
244
|
+
});
|
|
245
|
+
// A watched folder that's removed mustn't take the dev server down with it.
|
|
246
|
+
watcher.on('error', () => watcher.close());
|
|
247
|
+
watchers.push(watcher);
|
|
248
|
+
};
|
|
249
|
+
for (const c of Object.values(site.collections)) if (c.kind !== 'settings') add(c.dir, true);
|
|
250
|
+
const settings = join(site.root, SITE_SETTINGS_FILE);
|
|
251
|
+
add(dirname(settings), false, basename(settings));
|
|
252
|
+
return () => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
for (const watcher of watchers) watcher.close();
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
222
258
|
/**
|
|
223
259
|
* The hosts Astro may take a request's address from (`security.allowedDomains`).
|
|
224
260
|
* Without them, Astro ignores the Host header and every request reads as
|
|
@@ -377,6 +413,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
377
413
|
if (address.url) injectRoute({ pattern: SITEMAP_ROUTE.pattern, entrypoint: new URL(`./${SITEMAP_ROUTE.entrypoint}`, import.meta.url), prerender: true });
|
|
378
414
|
// M4 Phase 3: the sign-in pages (live/auth/routes.ts).
|
|
379
415
|
for (const route of LOGIN_ROUTES) injectRoute({ pattern: route.pattern, entrypoint: new URL(`./${route.entrypoint}`, import.meta.url), prerender: false });
|
|
416
|
+
// caracms-cloud M3 Decision 20: a cloud site's settings, awaited before anything renders. First of all.
|
|
417
|
+
addMiddleware({ order: 'pre', entrypoint: new URL(`./${CLOUD_MIDDLEWARE}`, import.meta.url) });
|
|
380
418
|
// M4 Phase 2: an unconfigured production server's 503 on /admin and /_caracms/*. First, so it runs before the guard.
|
|
381
419
|
addMiddleware({ order: 'pre', entrypoint: new URL(`./${EDITOR_MIDDLEWARE}`, import.meta.url) });
|
|
382
420
|
// M4 Phase 3: the sign-in guard on /admin and /_caracms/ (live mode only). Must stay after the 503 middleware.
|
|
@@ -461,16 +499,27 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
461
499
|
},
|
|
462
500
|
// M8 Decision 14: a dev server (at start, and on the restart a config change makes) regenerates
|
|
463
501
|
// schemas/ from the config. Never content files: a dev run only changes what your code change derives.
|
|
464
|
-
'astro:server:setup': ({ logger }) => {
|
|
502
|
+
'astro:server:setup': ({ server, logger }) => {
|
|
465
503
|
try {
|
|
466
504
|
const changed = writeSchemaFiles(site);
|
|
467
505
|
if (changed.length) logger.info(`updated ${changed.map((c) => c.file).join(', ')}`);
|
|
468
506
|
} catch (error) {
|
|
469
507
|
logger.warn(`couldn't write schemas/: ${(error as Error).message}`);
|
|
470
508
|
}
|
|
509
|
+
// A content change re-runs every route's getStaticPaths, as Astro does for its own content
|
|
510
|
+
// collections (the same event, to the same server environments). Never a browser reload:
|
|
511
|
+
// the editor writes these files.
|
|
512
|
+
const stop = watchContent(site, () => {
|
|
513
|
+
for (const name of ['ssr', 'prerender']) server.environments[name]?.hot.send('astro:content-changed', {});
|
|
514
|
+
});
|
|
515
|
+
server.httpServer?.once('close', stop);
|
|
471
516
|
},
|
|
472
517
|
// Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
|
|
473
|
-
|
|
518
|
+
// caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
|
|
519
|
+
'astro:build:start': async () => {
|
|
520
|
+
shareBuildReads(true);
|
|
521
|
+
await cloudSettingsForBuild();
|
|
522
|
+
},
|
|
474
523
|
'astro:build:done': () => {
|
|
475
524
|
shareBuildReads(false);
|
|
476
525
|
if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
|
package/src/live/README.md
CHANGED
|
@@ -19,7 +19,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
19
19
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
20
20
|
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
21
21
|
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts). A third credential, `cloud`: the token from CaraCMS Cloud, refreshed the same way; its refusals are an access problem of kind `cloud` |
|
|
22
|
-
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `cloudSettings()` (the repo, at start), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
22
|
+
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `fetchCloudSettings()` (the repo and the media; M3), `loadCloudSettings()` / `heldCloudSettings()` (one fetch per process, shared by the media and the runtime), `cloudSettings()` (the repo, at start), `cloudCall()` (one call, for the cloud bucket too), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
23
23
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
24
24
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
25
25
|
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import type { MiddlewareHandler } from 'astro';
|
|
26
26
|
import { site } from 'virtual:caracms/config';
|
|
27
27
|
import { editorMode } from '../../editor/server/mode.ts';
|
|
28
|
-
import {
|
|
28
|
+
import { isCloudRefusal, cloudUnavailable } from '../cloud.ts';
|
|
29
29
|
import { setPerson } from '../locals.ts';
|
|
30
30
|
import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
|
|
31
31
|
import { authFor } from './index.ts';
|
|
@@ -42,7 +42,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
|
42
42
|
try {
|
|
43
43
|
auth = await authFor(site, mode.settings, context.site);
|
|
44
44
|
} catch (error) {
|
|
45
|
-
if (error
|
|
45
|
+
if (isCloudRefusal(error)) {
|
|
46
46
|
console.error(`[caracms] sign-in: the live runtime could not start: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
47
47
|
return cloudUnavailable(error, context.url.pathname.startsWith('/_caracms/'));
|
|
48
48
|
}
|