caracms 0.7.0 → 0.8.1
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 +108 -19
- package/bin/caracms.mjs +11 -3
- package/bin/init.mjs +332 -34
- package/bin/media.mjs +60 -6
- package/bin/templates/railway-cloud.ts.txt +73 -0
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +30 -13
- package/skills/caracms-hosting/SKILL.md +240 -0
- package/skills/caracms-tina/SKILL.md +45 -15
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +51 -5
- package/src/config.ts +20 -1
- package/src/content.ts +15 -6
- package/src/document-problems.ts +48 -0
- package/src/editor/client/collections/CollectionGrid.tsx +2 -2
- package/src/editor/client/collections/CollectionTable.tsx +3 -3
- package/src/editor/client/documents/DocMenu.tsx +56 -1
- package/src/editor/client/documents/actions.ts +30 -1
- package/src/editor/client/documents/dialogs.tsx +61 -3
- package/src/editor/client/editor.ts +98 -2
- package/src/editor/client/fields/LockupField.tsx +4 -4
- package/src/editor/client/inbox/InboxStage.tsx +25 -1
- package/src/editor/client/live/notices.tsx +19 -0
- package/src/editor/client/live/publish.tsx +58 -2
- package/src/editor/client/nav/DocRow.tsx +4 -4
- package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
- package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
- package/src/editor/client/pane/parts.tsx +67 -4
- package/src/editor/client/pane/switcher.tsx +4 -4
- package/src/editor/client/shell/Bar.tsx +11 -0
- package/src/editor/client/state/keeper.ts +28 -4
- package/src/editor/client/state/live.ts +18 -1
- package/src/editor/routes/_live.ts +1 -1
- package/src/editor/routes/admin.astro +9 -0
- package/src/editor/routes/discard.ts +11 -0
- package/src/editor/server/admin-props.ts +18 -2
- package/src/editor/server/arrange.ts +4 -4
- package/src/editor/server/create.ts +4 -4
- package/src/editor/server/drafts-store.ts +2 -2
- package/src/editor/server/empty-site.ts +23 -0
- package/src/editor/server/media/cloud-mux.ts +4 -1
- package/src/editor/server/media/config.ts +12 -5
- package/src/editor/server/media/upload.ts +2 -41
- package/src/editor/server/mode.ts +3 -1
- package/src/editor/server/move.ts +32 -4
- package/src/editor/server/write.ts +4 -4
- package/src/editor/styles/levels/live.css +2 -0
- package/src/editor/types.ts +92 -4
- package/src/email.ts +4 -1
- package/src/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +21 -4
- package/src/index.ts +21 -6
- package/src/live/auth/login.ts +4 -3
- package/src/live/auth/views.ts +8 -1
- package/src/live/autosaves.ts +94 -6
- package/src/live/cloud.ts +64 -20
- package/src/live/drafts-store.ts +17 -6
- package/src/live/editor.ts +232 -16
- package/src/live/git.ts +492 -22
- package/src/live/github.ts +84 -17
- package/src/live/locks.ts +22 -0
- package/src/live/messages.ts +49 -2
- package/src/live/mode.ts +19 -8
- package/src/live/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/services.ts +114 -0
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
- package/src/media.ts +9 -3
package/src/live/sweep.ts
CHANGED
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* → `ancestorFor`), a "gone" one as a removed file (git.ts → `commitDrafts`). A newer
|
|
26
26
|
* write that came in while a new document was being committed is no longer a new
|
|
27
27
|
* document: its `created` mark is dropped (retried if a write beats the swap), so its ancestor is that commit.
|
|
28
|
+
* - **`exclusively`** runs other work under the sweep's own locks (this process's queue and
|
|
29
|
+
* the Redis lock): Discard changes, so no tick commits the edit being thrown away while
|
|
30
|
+
* it's being thrown away. It waits a few seconds for another server's tick, then gives
|
|
31
|
+
* up (`SweepBusyError`) rather than go ahead without the lock.
|
|
28
32
|
*/
|
|
29
33
|
import { randomBytes } from 'node:crypto';
|
|
30
34
|
import type { DraftEntry, LiveGit } from './git.ts';
|
|
@@ -64,6 +68,14 @@ export interface SweepResult {
|
|
|
64
68
|
|
|
65
69
|
type Select = 'ready' | 'all' | { files: string[] };
|
|
66
70
|
|
|
71
|
+
/** `exclusively` couldn't get the sweep's Redis lock: another server's tick held it throughout. */
|
|
72
|
+
export class SweepBusyError extends Error {
|
|
73
|
+
constructor() {
|
|
74
|
+
super('the sweep is busy on another server');
|
|
75
|
+
this.name = 'SweepBusyError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
/** Is an autosave ready for a tick: marked to go at once, idle long enough, or past the cap? */
|
|
68
80
|
export function ready(a: Autosave, now: number, timings: Pick<LiveTimings, 'sweepIdleMs' | 'sweepCapMs'>): boolean {
|
|
69
81
|
return a.sweepAtOnce === true || now - a.updatedAt >= timings.sweepIdleMs || now - a.firstAt >= timings.sweepCapMs;
|
|
@@ -90,11 +102,16 @@ export class Sweep {
|
|
|
90
102
|
this.lockKey = `${options.prefix ?? 'caracms:'}sweep:lock`;
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The startup check: anything a previous server left behind starts the ticking. An
|
|
107
|
+
* autosave that still carries the week's expiry older versions gave it loses it
|
|
108
|
+
* (caracms-cloud adversarial review, finding 4: an uncommitted edit never expires).
|
|
109
|
+
*/
|
|
94
110
|
async start(): Promise<void> {
|
|
95
111
|
const pending = await this.o.autosaves.all();
|
|
96
112
|
if (pending.length) {
|
|
97
113
|
this.log(`sweep: ${pending.length} autosave(s) waiting from before this server started`);
|
|
114
|
+
for (const a of pending) await this.o.autosaves.keepForever(a);
|
|
98
115
|
this.poke();
|
|
99
116
|
}
|
|
100
117
|
}
|
|
@@ -151,6 +168,32 @@ export class Sweep {
|
|
|
151
168
|
return (await this.run({ files })).result;
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Run `fn` holding the sweep's locks: after any run of this process's in flight, and with
|
|
173
|
+
* the Redis lock (waiting up to about 5 s for another server's tick, else SweepBusyError).
|
|
174
|
+
*/
|
|
175
|
+
exclusively<T>(fn: () => Promise<T>): Promise<T> {
|
|
176
|
+
const work = async (): Promise<T> => {
|
|
177
|
+
const { redis, timings } = this.o;
|
|
178
|
+
const token = randomBytes(9).toString('base64url');
|
|
179
|
+
const lockMs = Math.max(30_000, timings.sweepTickMs);
|
|
180
|
+
let locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
|
|
181
|
+
for (let i = 0; i < 20 && !locked; i++) {
|
|
182
|
+
await this.sleep(250);
|
|
183
|
+
locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
|
|
184
|
+
}
|
|
185
|
+
if (!locked) throw new SweepBusyError();
|
|
186
|
+
try {
|
|
187
|
+
return await fn();
|
|
188
|
+
} finally {
|
|
189
|
+
await redis.compareAndDelete(this.lockKey, token).catch(() => false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const next = this.running.then(work, work);
|
|
193
|
+
this.running = next.catch(() => {});
|
|
194
|
+
return next;
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
/** Runs one at a time in this process; the Redis lock keeps two servers apart. */
|
|
155
198
|
private run(select: Select): Promise<{ result: SweepResult; left: boolean }> {
|
|
156
199
|
const next = this.running.then(
|
package/src/live/webhook.ts
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* compared in constant time, or it's 401 and nothing happens.
|
|
9
9
|
* - **Nothing in the body is trusted or even read**: a good signature only prompts
|
|
10
10
|
* Decision 8's check, which asks GitHub itself what moved.
|
|
11
|
+
* - **At most 25 MB is read**, counted as it comes (body.ts): a chunked body with no
|
|
12
|
+
* `Content-Length`, or one that lies, is refused at the cap, not after (finding 10).
|
|
11
13
|
*/
|
|
12
14
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { readCapped } from '../body.ts';
|
|
13
16
|
|
|
14
17
|
/** Largest body read. GitHub caps payloads at 25 MB; a push is far smaller, and it's never parsed. */
|
|
15
18
|
const MAX_BODY_BYTES = 25 * 1024 * 1024;
|
|
@@ -30,9 +33,11 @@ const text = (status: number, body: string) => new Response(body, { status, head
|
|
|
30
33
|
export async function handleGithubWebhook(request: Request, options: { secret: string | null; trigger: () => Promise<unknown> }): Promise<Response> {
|
|
31
34
|
if (!options.secret) return text(404, 'Not found');
|
|
32
35
|
if (request.method !== 'POST') return new Response('The webhook only takes POST.', { status: 405, headers: { Allow: 'POST' } });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
// caracms-cloud adversarial review, finding 10: the bytes are counted as they're read and the read
|
|
37
|
+
// stops at the cap, with or without a Content-Length, before the signature is checked.
|
|
38
|
+
const read = await readCapped(request, MAX_BODY_BYTES);
|
|
39
|
+
if (!read.ok) return text(413, 'Too large.');
|
|
40
|
+
const body = Buffer.from(read.bytes.buffer, read.bytes.byteOffset, read.bytes.byteLength);
|
|
36
41
|
if (!signatureMatches(options.secret, body, request.headers.get('x-hub-signature-256'))) return text(401, 'Bad signature.');
|
|
37
42
|
void options.trigger().catch((error: unknown) => console.error('[caracms] the check after a webhook failed:', error));
|
|
38
43
|
return text(202, 'Checking.');
|
package/src/media.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { createHmac } from 'node:crypto';
|
|
|
35
35
|
import type { ImageFormat } from './fields.ts';
|
|
36
36
|
import { CLOUD_NO_MEDIA, heldCloudSettings, type CloudConnection } from './live/cloud.ts';
|
|
37
37
|
import { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES, readCloudUrl } from './live/mode.ts';
|
|
38
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
|
|
38
39
|
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
39
40
|
|
|
40
41
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
@@ -92,8 +93,10 @@ export const CLOUD_MEDIA_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so im
|
|
|
92
93
|
* The cloud as the media credential: the connection when `CARACMS_CLOUD_KEY` is set and
|
|
93
94
|
* `CARACMS_MEDIA` isn't `memory` (which wins over everything), with any problems (the
|
|
94
95
|
* site's own media variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site's media.
|
|
96
|
+
* With the config's `services` (caracms-cloud M5), the other mode's variables aren't read.
|
|
95
97
|
*/
|
|
96
|
-
export function cloudMedia(env: Env = process.env): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
98
|
+
export function cloudMedia(env: Env = process.env, services: Services | null = bakedServices()): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
99
|
+
env = servicesEnv(env, services);
|
|
97
100
|
const get = (name: string) => env[name]?.trim() || null;
|
|
98
101
|
const key = get('CARACMS_CLOUD_KEY');
|
|
99
102
|
if (!key || get('CARACMS_MEDIA') === 'memory') return null;
|
|
@@ -117,7 +120,8 @@ function cloudMediaConfig(cloud: NonNullable<ReturnType<typeof cloudMedia>>, pro
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
|
|
120
|
-
export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
|
|
123
|
+
export function readMediaConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MediaConfig {
|
|
124
|
+
env = servicesEnv(env, services);
|
|
121
125
|
const get = (name: string) => {
|
|
122
126
|
const v = env[name]?.trim();
|
|
123
127
|
return v ? v : null;
|
|
@@ -129,7 +133,9 @@ export function readMediaConfig(env: Env = process.env, production = isProductio
|
|
|
129
133
|
return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
|
|
130
134
|
}
|
|
131
135
|
if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
|
|
132
|
-
|
|
136
|
+
// M5 Decision 4: a cloud site without its key has no media, never the site's own instead.
|
|
137
|
+
if (services === 'cloud' && !get('CARACMS_CLOUD_KEY')) return { kind: 'unset', missing: [], problems: [...problems, CLOUD_KEY_MISSING_PROBLEM] };
|
|
138
|
+
const cloud = cloudMedia(env, services);
|
|
133
139
|
if (cloud) return cloudMediaConfig(cloud, problems);
|
|
134
140
|
const missing: string[] = [];
|
|
135
141
|
const need = (name: string) => {
|