caracms 0.8.0 → 0.8.2
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 +23 -2
- package/package.json +1 -1
- package/skills/caracms-hosting/SKILL.md +1 -1
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +39 -3
- 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/upload.ts +2 -41
- 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/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +14 -2
- package/src/index.ts +23 -8
- 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/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
package/src/live/github.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* - **Every request times out** after `timeoutMs` (20 s), its body included: a GitHub
|
|
17
17
|
* that stops answering fails the operation instead of holding it (and Publish's or
|
|
18
|
-
* the check's callers) forever.
|
|
18
|
+
* the check's callers) forever. The cloud's token call is the one exception: 35 s
|
|
19
|
+
* unless `timeoutMs` is given (cloud.ts, `CLOUD_TOKEN_TIMEOUT_MS`).
|
|
19
20
|
*
|
|
20
21
|
* - **Access problems** (Decision 5): every response is watched for a refused credential
|
|
21
22
|
* (401), a write refused for lack of a permission (403 "not accessible"), and, for a
|
|
@@ -25,13 +26,21 @@
|
|
|
25
26
|
* - **CaraCMS Cloud** (`CARACMS_CLOUD_KEY`; caracms-cloud M2 Decisions 12–14) mints the
|
|
26
27
|
* installation token instead: `POST /v1/github/token` (cloud.ts), refreshed five minutes
|
|
27
28
|
* early like the App's. A refusal from the cloud is an access problem of kind `cloud`
|
|
28
|
-
* carrying the cloud's sentence (and its install link), cleared by the next token
|
|
29
|
-
*
|
|
29
|
+
* carrying the cloud's sentence (and its install link), cleared by the next token.
|
|
30
|
+
* - **A rename or transfer on GitHub** (caracms-cloud adversarial review, finding 7; this
|
|
31
|
+
* retires M2 Decision 13's refusal of a token answer for another repo, whose only way out
|
|
32
|
+
* was a developer restarting the server): the settings are read again, and when they name
|
|
33
|
+
* the new repo too, the client takes that name for the rest of the process. Every path,
|
|
34
|
+
* GraphQL variable and commit asks for it from then on (the backend reads `owner`, `repo`
|
|
35
|
+
* and `nameWithOwner` on each call), a call that was waiting on the token is moved to it,
|
|
36
|
+
* one line is logged and the editor gets an information note (`renamed`) until the next
|
|
37
|
+
* restart. Settings that don't name it yet, or can't be read, fail that refresh as an
|
|
38
|
+
* access problem of kind `cloud`; the next token asks again.
|
|
30
39
|
*
|
|
31
40
|
* The private key, the site key and the tokens are never logged.
|
|
32
41
|
*/
|
|
33
42
|
import { createSign } from 'node:crypto';
|
|
34
|
-
import { CLOUD_REPO_CHANGED, CloudRefusal, isCloudRefusal, cloudToken } from './cloud.ts';
|
|
43
|
+
import { CLOUD_REPO_CHANGED, CloudRefusal, confirmCloudRepo, isCloudRefusal, cloudToken } from './cloud.ts';
|
|
35
44
|
|
|
36
45
|
const API = 'https://api.github.com';
|
|
37
46
|
|
|
@@ -61,6 +70,8 @@ export interface TokenConnection extends ConnectionBase {
|
|
|
61
70
|
export interface CloudConnection extends ConnectionBase {
|
|
62
71
|
cloudKey: string;
|
|
63
72
|
cloudUrl: string;
|
|
73
|
+
/** Finding 7's one line when the repo's new name is taken. Default: the console, as `[caracms] …`. */
|
|
74
|
+
log?: (line: string) => void;
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
export type GitHubConnection = AppConnection | TokenConnection | CloudConnection;
|
|
@@ -77,7 +88,9 @@ export type GitHubAccessProblem =
|
|
|
77
88
|
| { kind: 'permission'; credential: GitHubCredential; permission: string }
|
|
78
89
|
| { kind: 'expiring'; credential: 'token'; expires: string }
|
|
79
90
|
/** CaraCMS Cloud refused a token (caracms-cloud M2 Decision 14): its sentence as it came, and its install link. */
|
|
80
|
-
| { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null }
|
|
91
|
+
| { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null }
|
|
92
|
+
/** Not a problem: information (finding 7). The repo was renamed or transferred, and work carries on under `repo`. */
|
|
93
|
+
| { kind: 'renamed'; credential: 'cloud'; repo: string; was: string };
|
|
81
94
|
|
|
82
95
|
/** A token expiring within this long shows in the editor (Decision 5). */
|
|
83
96
|
export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
|
|
@@ -115,6 +128,8 @@ export function accessProblemMessage(problem: GitHubAccessProblem): string {
|
|
|
115
128
|
return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
|
|
116
129
|
case 'cloud':
|
|
117
130
|
return problem.message;
|
|
131
|
+
case 'renamed':
|
|
132
|
+
return `This site's GitHub repo is now ${problem.repo} (it was ${problem.was}). Changes save and publish to it as before; nothing needs doing.`;
|
|
118
133
|
case 'expiring': {
|
|
119
134
|
const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
120
135
|
return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
|
|
@@ -229,8 +244,6 @@ function shorten(p: string): string {
|
|
|
229
244
|
|
|
230
245
|
export class GitHubClient {
|
|
231
246
|
readonly tracker: Tracker;
|
|
232
|
-
readonly owner: string;
|
|
233
|
-
readonly repo: string;
|
|
234
247
|
/** Which credential this client signs in with. */
|
|
235
248
|
readonly credential: GitHubCredential;
|
|
236
249
|
private readonly appId: string;
|
|
@@ -239,6 +252,8 @@ export class GitHubClient {
|
|
|
239
252
|
private readonly fetcher: typeof fetch;
|
|
240
253
|
private readonly now: () => number;
|
|
241
254
|
private readonly timeoutMs: number;
|
|
255
|
+
/** The connection's own `timeoutMs` (tests), else undefined: the token call then waits its 35 s (cloud.ts). */
|
|
256
|
+
private readonly givenTimeoutMs: number | undefined;
|
|
242
257
|
private token = '';
|
|
243
258
|
private tokenExpires = 0;
|
|
244
259
|
private signingIn: Promise<void> | null = null;
|
|
@@ -248,6 +263,11 @@ export class GitHubClient {
|
|
|
248
263
|
private personalTokenExpires: number | null = null;
|
|
249
264
|
/** The cloud's last refusal, until a token comes. */
|
|
250
265
|
private cloudProblem: { message: string; installUrl: string | null } | null = null;
|
|
266
|
+
/** The repo, as the server started on it or as a rename made it since (finding 7). */
|
|
267
|
+
private name: { owner: string; repo: string };
|
|
268
|
+
/** The name before the last rename taken, for the editor's note until a restart. */
|
|
269
|
+
private renamedFrom: string | null = null;
|
|
270
|
+
private readonly log: (line: string) => void = (line) => console.log(`[caracms] ${line}`);
|
|
251
271
|
|
|
252
272
|
constructor(connection: GitHubConnection, tracker: Tracker) {
|
|
253
273
|
if ('token' in connection) {
|
|
@@ -261,16 +281,17 @@ export class GitHubClient {
|
|
|
261
281
|
this.appId = '';
|
|
262
282
|
this.key = '';
|
|
263
283
|
this.cloud = { key: connection.cloudKey, url: connection.cloudUrl };
|
|
284
|
+
if (connection.log) this.log = connection.log;
|
|
264
285
|
} else {
|
|
265
286
|
this.credential = 'app';
|
|
266
287
|
this.appId = connection.appId;
|
|
267
288
|
this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
|
|
268
289
|
}
|
|
269
|
-
this.
|
|
270
|
-
this.repo = connection.repo;
|
|
290
|
+
this.name = { owner: connection.owner, repo: connection.repo };
|
|
271
291
|
this.fetcher = connection.fetch ?? fetch;
|
|
272
292
|
this.now = connection.now ?? Date.now;
|
|
273
293
|
this.timeoutMs = connection.timeoutMs ?? GITHUB_TIMEOUT_MS;
|
|
294
|
+
this.givenTimeoutMs = connection.timeoutMs;
|
|
274
295
|
this.tracker = tracker;
|
|
275
296
|
}
|
|
276
297
|
|
|
@@ -283,6 +304,7 @@ export class GitHubClient {
|
|
|
283
304
|
if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
|
|
284
305
|
return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
|
|
285
306
|
}
|
|
307
|
+
if (this.renamedFrom) return { kind: 'renamed', credential: 'cloud', repo: this.nameWithOwner, was: this.renamedFrom };
|
|
286
308
|
return null;
|
|
287
309
|
}
|
|
288
310
|
|
|
@@ -306,6 +328,14 @@ export class GitHubClient {
|
|
|
306
328
|
if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
|
|
307
329
|
}
|
|
308
330
|
|
|
331
|
+
get owner() {
|
|
332
|
+
return this.name.owner;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
get repo() {
|
|
336
|
+
return this.name.repo;
|
|
337
|
+
}
|
|
338
|
+
|
|
309
339
|
get nameWithOwner() {
|
|
310
340
|
return `${this.owner}/${this.repo}`;
|
|
311
341
|
}
|
|
@@ -339,15 +369,25 @@ export class GitHubClient {
|
|
|
339
369
|
private async cloudSignIn(cloud: { key: string; url: string }): Promise<void> {
|
|
340
370
|
let answer;
|
|
341
371
|
try {
|
|
342
|
-
|
|
372
|
+
// Seam note (caracms-cloud adversarial review): the token route's worst case passes 20 s, so
|
|
373
|
+
// it gets cloud.ts's 35 s; the settings read below keeps the 20 s every other call has.
|
|
374
|
+
answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.givenTimeoutMs });
|
|
343
375
|
} catch (error) {
|
|
344
376
|
if (isCloudRefusal(error) && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
|
|
345
377
|
throw error;
|
|
346
378
|
}
|
|
347
379
|
if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
380
|
+
// Finding 7: a rename or transfer. Taken only when the settings, read again, name it too.
|
|
381
|
+
const repo = await confirmCloudRepo({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs }, answer.repo);
|
|
382
|
+
if (!repo) {
|
|
383
|
+
const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
|
|
384
|
+
this.cloudProblem = { message: refusal.message, installUrl: null };
|
|
385
|
+
throw refusal;
|
|
386
|
+
}
|
|
387
|
+
const was = this.nameWithOwner;
|
|
388
|
+
this.name = repo;
|
|
389
|
+
this.renamedFrom = was;
|
|
390
|
+
this.log(`the site's repo is now ${this.nameWithOwner} (was ${was})`);
|
|
351
391
|
}
|
|
352
392
|
this.cloudProblem = null;
|
|
353
393
|
this.token = answer.token;
|
|
@@ -372,18 +412,43 @@ export class GitHubClient {
|
|
|
372
412
|
}
|
|
373
413
|
|
|
374
414
|
async rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
|
375
|
-
|
|
415
|
+
const was = this.nameWithOwner;
|
|
416
|
+
const bearer = await this.auth();
|
|
417
|
+
return (await this.request(method, this.movedPath(path, was), body, bearer)) as T;
|
|
376
418
|
}
|
|
377
419
|
|
|
378
420
|
/** A GET whose body is bytes (the tarball). Follows GitHub's redirect to codeload. */
|
|
379
421
|
async bytes(path: string): Promise<Buffer> {
|
|
380
|
-
const
|
|
422
|
+
const was = this.nameWithOwner;
|
|
423
|
+
const bearer = await this.auth();
|
|
424
|
+
path = this.movedPath(path, was);
|
|
425
|
+
const res = await this.fetcher(`${API}${path}`, { headers: this.headers(bearer), redirect: 'follow', signal: this.signal() });
|
|
381
426
|
this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
|
|
382
427
|
this.observe(res, path, false, false);
|
|
383
428
|
if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
|
|
384
429
|
return Buffer.from(await res.arrayBuffer());
|
|
385
430
|
}
|
|
386
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Finding 7: a call's path was made on `was` (its caller read `repoPath()` before the
|
|
434
|
+
* token), and the token it waited for took a new name. It goes to the new name, so no
|
|
435
|
+
* call after a rename leans on GitHub's redirect from the old one.
|
|
436
|
+
*/
|
|
437
|
+
private movedPath(path: string, was: string): string {
|
|
438
|
+
const from = `/repos/${was}`;
|
|
439
|
+
return was !== this.nameWithOwner && (path === from || path.startsWith(`${from}/`) || path.startsWith(`${from}?`)) ? `/repos/${this.nameWithOwner}${path.slice(from.length)}` : path;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The same for GraphQL's variables: an `owner` and `name` pair, or a `repositoryNameWithOwner`, on `was`. */
|
|
443
|
+
private movedVariables(value: unknown, was: string): unknown {
|
|
444
|
+
if (was === this.nameWithOwner || !value || typeof value !== 'object') return value;
|
|
445
|
+
if (Array.isArray(value)) return value.map((v) => this.movedVariables(v, was));
|
|
446
|
+
const o = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.movedVariables(v, was)]));
|
|
447
|
+
if (o.repositoryNameWithOwner === was) o.repositoryNameWithOwner = this.nameWithOwner;
|
|
448
|
+
if (`${o.owner}/${o.name}` === was) Object.assign(o, { owner: this.owner, name: this.repo });
|
|
449
|
+
return o;
|
|
450
|
+
}
|
|
451
|
+
|
|
387
452
|
/** Aborts the request (and the reading of its body) after `timeoutMs`. */
|
|
388
453
|
private signal(): AbortSignal {
|
|
389
454
|
return AbortSignal.timeout(this.timeoutMs);
|
|
@@ -427,11 +492,13 @@ export class GitHubClient {
|
|
|
427
492
|
/** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
|
|
428
493
|
async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
|
|
429
494
|
const isMutation = /^\s*mutation\b/.test(query);
|
|
495
|
+
const was = this.nameWithOwner;
|
|
496
|
+
const bearer = await this.auth();
|
|
430
497
|
const res = await this.fetcher(`${API}/graphql`, {
|
|
431
498
|
method: 'POST',
|
|
432
499
|
// updateRefs worked with this preview header in M1 (G2); whether it still needs it is untested (G6 (f)).
|
|
433
|
-
headers: { ...this.headers(
|
|
434
|
-
body: JSON.stringify({ query, variables }),
|
|
500
|
+
headers: { ...this.headers(bearer, true), accept: 'application/vnd.github.update-refs-preview+json, application/json' },
|
|
501
|
+
body: JSON.stringify({ query, variables: this.movedVariables(variables, was) }),
|
|
435
502
|
signal: this.signal(),
|
|
436
503
|
});
|
|
437
504
|
this.tracker.record({ endpoint: `graphql ${name}`, status: res.status, creating: isMutation });
|
package/src/live/locks.ts
CHANGED
|
@@ -302,6 +302,28 @@ export class Locks {
|
|
|
302
302
|
this.cache?.hash(this.presenceKey(), fields);
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/** The tabs on this document now (polled with it within their record's hold), with their people (Discard changes). */
|
|
306
|
+
async tabsOn(file: string): Promise<{ tab: string; person: Person }[]> {
|
|
307
|
+
const now = this.now();
|
|
308
|
+
const out: { tab: string; person: Person }[] = [];
|
|
309
|
+
for (const [tab, raw] of Object.entries(await this.redis.hgetall(this.presenceKey()))) {
|
|
310
|
+
try {
|
|
311
|
+
const r = JSON.parse(raw) as PresenceRecord;
|
|
312
|
+
const hold = typeof r.hold === 'number' ? r.hold : this.timings.presenceMs;
|
|
313
|
+
if (r.file === file && now - r.at <= hold) out.push({ tab, person: r.person });
|
|
314
|
+
} catch {
|
|
315
|
+
/* skip */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Discard changes: let go of a lock whoever holds it (only while it's still what was read). */
|
|
322
|
+
async drop(read: ReadLock, file: string): Promise<void> {
|
|
323
|
+
if (await landed(this.cache, this.key(file), () => this.redis.compareAndDelete(this.key(file), read.raw))) this.cache?.string(this.key(file), null);
|
|
324
|
+
else this.cache?.drop(this.key(file));
|
|
325
|
+
}
|
|
326
|
+
|
|
305
327
|
/** Who is on which document now (polled with it within the record's hold). Long-gone tabs are pruned. */
|
|
306
328
|
async presence(): Promise<PresenceView[]> {
|
|
307
329
|
return this.presenceFrom(Object.entries(await this.redis.hgetall(this.presenceKey())));
|
package/src/live/messages.ts
CHANGED
|
@@ -31,6 +31,25 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Published-by: Aoife <aoife@fernhill.ie>
|
|
33
33
|
* Published-files: content/pages/index.json, content/pages/about.json
|
|
34
|
+
*
|
|
35
|
+
* Discarding a document's unpublished changes (its files put back as `main` has them):
|
|
36
|
+
*
|
|
37
|
+
* Discarded: About — by Aoife
|
|
38
|
+
*
|
|
39
|
+
* Discarded-by: Aoife <aoife@fernhill.ie>
|
|
40
|
+
* Discarded-files: content/pages/about.json
|
|
41
|
+
*
|
|
42
|
+
* Publish holding documents back (caracms-cloud adversarial review, finding 15): the one
|
|
43
|
+
* commit on `drafts`, on top of the publish, putting back what `main` didn't get. It says
|
|
44
|
+
* again who changed each file, so they're still waiting with who changed them:
|
|
45
|
+
*
|
|
46
|
+
* Held back: About
|
|
47
|
+
*
|
|
48
|
+
* Held-back-files: content/pages/about.json
|
|
49
|
+
* Edited-by: Ciarán <ciaran@fernhill.ie>: content/pages/about.json
|
|
50
|
+
*
|
|
51
|
+
* Reading back, a discard ends what the commits before it said about those files: who
|
|
52
|
+
* changed them and any `Draft-kept` (status.ts → `waitingFromCommits`).
|
|
34
53
|
*/
|
|
35
54
|
|
|
36
55
|
export interface Person {
|
|
@@ -86,6 +105,28 @@ export function mergeMessage(fromMain: string[], keptDraft: string[]): string {
|
|
|
86
105
|
return `Merge main into drafts${lines.length ? `\n\n${lines.join('\n')}` : ''}`;
|
|
87
106
|
}
|
|
88
107
|
|
|
108
|
+
/** A discard's commit on `drafts`: the document's files put back as `main` has them. */
|
|
109
|
+
export function discardMessage(titles: string[], files: string[], by: Person): string {
|
|
110
|
+
return `Discarded: ${titleList(titles)} — by ${displayName(by)}\n\nDiscarded-by: ${ident(by)}\nDiscarded-files: ${files.join(', ')}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Finding 15: Publish's commit on `drafts` putting back the files it held back. `waiting`: who changed each, newest first (status.ts → waitingFromCommits). */
|
|
114
|
+
export function heldMessage(titles: string[], files: string[], waiting: { file: string; editors: { person: Person }[]; keptDraft: boolean }[]): string {
|
|
115
|
+
const byEditor = new Map<string, { person: Person; files: string[] }>();
|
|
116
|
+
// Oldest first, so reading back puts the newest editor in front again.
|
|
117
|
+
for (const w of waiting)
|
|
118
|
+
for (const e of [...w.editors].reverse()) {
|
|
119
|
+
const key = e.person.email.toLowerCase();
|
|
120
|
+
const entry = byEditor.get(key) ?? { person: e.person, files: [] };
|
|
121
|
+
entry.files.push(w.file);
|
|
122
|
+
byEditor.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
const lines = [`Held-back-files: ${files.join(', ')}`, ...[...byEditor.values()].map(({ person, files }) => `Edited-by: ${ident(person)}: ${files.join(', ')}`)];
|
|
125
|
+
const kept = waiting.filter((w) => w.keptDraft).map((w) => w.file);
|
|
126
|
+
if (kept.length) lines.push(`Draft-kept: ${kept.join(', ')}`);
|
|
127
|
+
return `Held back: ${titleList(titles)}\n\n${lines.join('\n')}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
89
130
|
export function publishMessage(titles: string[], files: string[], by: Person): string {
|
|
90
131
|
return `Published: ${titleList(titles)} — by ${displayName(by)}\n\nPublished-by: ${ident(by)}\nPublished-files: ${files.join(', ')}`;
|
|
91
132
|
}
|
|
@@ -93,7 +134,7 @@ export function publishMessage(titles: string[], files: string[], by: Person): s
|
|
|
93
134
|
// ── Reading them back ─────────────────────────────────────────────────────────
|
|
94
135
|
|
|
95
136
|
export interface ParsedMessage {
|
|
96
|
-
kind: 'draft' | 'merge' | 'publish' | 'other';
|
|
137
|
+
kind: 'draft' | 'merge' | 'publish' | 'discard' | 'other';
|
|
97
138
|
/** Edited-by trailers: who changed which files. */
|
|
98
139
|
edits: { person: Person; files: string[] }[];
|
|
99
140
|
/** Draft-kept: files where the draft won a conflict. */
|
|
@@ -104,6 +145,8 @@ export interface ParsedMessage {
|
|
|
104
145
|
publishedFiles: string[];
|
|
105
146
|
/** The titles in a Draft or Published headline (without "and N more"). */
|
|
106
147
|
titles: string[];
|
|
148
|
+
/** Discarded-files, for a discard: what came before about these files no longer counts. */
|
|
149
|
+
discardedFiles: string[];
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
const list = (value: string) =>
|
|
@@ -123,7 +166,7 @@ const parsePerson = (value: string): Person | null => {
|
|
|
123
166
|
/** Read a message written by the functions above. Anything else parses as `other` with no data. */
|
|
124
167
|
export function parseMessage(message: string): ParsedMessage {
|
|
125
168
|
const [headline = '', ...rest] = message.split('\n');
|
|
126
|
-
const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [] };
|
|
169
|
+
const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [], discardedFiles: [] };
|
|
127
170
|
const titles = (s: string) => list(s.replace(/ and \d+ more$/, ''));
|
|
128
171
|
if (headline.startsWith('Draft: ')) {
|
|
129
172
|
out.kind = 'draft';
|
|
@@ -132,6 +175,9 @@ export function parseMessage(message: string): ParsedMessage {
|
|
|
132
175
|
else if (headline.startsWith('Published: ')) {
|
|
133
176
|
out.kind = 'publish';
|
|
134
177
|
out.titles = titles(headline.slice('Published: '.length).replace(/ — by .*$/, ''));
|
|
178
|
+
} else if (headline.startsWith('Discarded: ')) {
|
|
179
|
+
out.kind = 'discard';
|
|
180
|
+
out.titles = titles(headline.slice('Discarded: '.length).replace(/ — by .*$/, ''));
|
|
135
181
|
}
|
|
136
182
|
for (const line of rest) {
|
|
137
183
|
const m = /^([A-Za-z-]+): (.*)$/.exec(line.trim());
|
|
@@ -146,6 +192,7 @@ export function parseMessage(message: string): ParsedMessage {
|
|
|
146
192
|
} else if (key === 'Draft-kept') out.keptDraft.push(...list(value!));
|
|
147
193
|
else if (key === 'Published-by') out.publishedBy = parsePerson(value!);
|
|
148
194
|
else if (key === 'Published-files') out.publishedFiles = list(value!);
|
|
195
|
+
else if (key === 'Discarded-files' && out.kind === 'discard') out.discardedFiles = list(value!);
|
|
149
196
|
}
|
|
150
197
|
return out;
|
|
151
198
|
}
|
package/src/live/notices.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - **"Published as it stood"** (`caracms:notices`, a hash by notice id): Publish leaves
|
|
6
6
|
* one for each person who had a published document open; dismissing it deletes it.
|
|
7
|
+
* - **"Discarded"** (Discard changes), in the same hash: one for each person who had edits in
|
|
8
|
+
* the document or had it open, but didn't discard it; dismissing it deletes it.
|
|
7
9
|
* - **Dismissed "draft kept" notices** (`caracms:dismissed`, a set of ids): those are
|
|
8
10
|
* shared, worked out from git (`git.keptDraftNotices()`), so a dismissal is remembered
|
|
9
11
|
* here; a new conflict has a new id and shows again.
|
|
@@ -25,6 +27,8 @@ export interface StoodNotice {
|
|
|
25
27
|
title: string;
|
|
26
28
|
by: Person;
|
|
27
29
|
at: string;
|
|
30
|
+
/** Absent: "published as it stood". `discarded`: "Aoife discarded the unpublished changes to About". */
|
|
31
|
+
kind?: 'discarded';
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export class Notices {
|
|
@@ -90,7 +94,7 @@ export class Notices {
|
|
|
90
94
|
for (const raw of stood) {
|
|
91
95
|
try {
|
|
92
96
|
const n = JSON.parse(raw) as StoodNotice;
|
|
93
|
-
if (n.for === email) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
|
|
97
|
+
if (n.for === email) out.push({ id: n.id, kind: n.kind === 'discarded' ? 'discarded' : 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
|
|
94
98
|
} catch {
|
|
95
99
|
/* skip */
|
|
96
100
|
}
|
package/src/live/runtime.ts
CHANGED
|
@@ -93,8 +93,9 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
93
93
|
if (gh.kind === 'cloud') {
|
|
94
94
|
// caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
|
|
95
95
|
// token, so a refusal of either stops the start (the sign-in guard shows its sentence).
|
|
96
|
+
// A rename on GitHub after this is taken by the client (adversarial review, finding 7).
|
|
96
97
|
const { owner, repo } = await cloudRepoForStart({ key: gh.key, url: gh.url, fetch });
|
|
97
|
-
client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
|
|
98
|
+
client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch, log }, tracker);
|
|
98
99
|
await client.ready();
|
|
99
100
|
log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
|
|
100
101
|
} else {
|
package/src/live/status.ts
CHANGED
|
@@ -29,6 +29,11 @@ export function waitingFromCommits(files: Iterable<string>, commits: CommitSumma
|
|
|
29
29
|
for (const file of files) out.set(file, { file, editors: [], changedAt: null, keptDraft: false });
|
|
30
30
|
for (const commit of commits) {
|
|
31
31
|
const parsed = parseMessage(commit.message);
|
|
32
|
+
// A discard: what came before about its files no longer counts (they matched `main` after it).
|
|
33
|
+
for (const file of parsed.discardedFiles) {
|
|
34
|
+
const w = out.get(file);
|
|
35
|
+
if (w) Object.assign(w, { editors: [], changedAt: null, keptDraft: false });
|
|
36
|
+
}
|
|
32
37
|
for (const edit of parsed.edits) {
|
|
33
38
|
for (const file of edit.files) {
|
|
34
39
|
const w = out.get(file);
|
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.');
|