caracms 0.8.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 +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 +18 -5
- 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/drafts-store.ts
CHANGED
|
@@ -80,12 +80,16 @@ export function unavailable(error: unknown): WriteRefused {
|
|
|
80
80
|
* expire, so a file with none was never changed in the editor or Redis lost its data, and
|
|
81
81
|
* the server can't tell which: the keeper holds edits on unknown rather than re-send them.
|
|
82
82
|
*/
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here.
|
|
85
|
+
* Discard changes: its `discard` is `discarded`, so a browser drops edits held from before it.
|
|
86
|
+
*/
|
|
84
87
|
export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
|
|
85
88
|
const record = known !== undefined ? known : await parts.autosaves.changed(file);
|
|
86
|
-
|
|
89
|
+
const discarded = record?.discard ? { discarded: { by: record.discard.by, at: record.discard.at } } : {};
|
|
90
|
+
if (record) return { by: record.by, version: record.version, at: record.at, ...discarded };
|
|
87
91
|
const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
|
|
88
|
-
return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
|
|
92
|
+
return editor ? { by: editor.person, version: null, at: editor.at, ...discarded } : { by: null, version: null, at: null, known: false, ...discarded };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
@@ -400,7 +404,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
400
404
|
guard(async () => {
|
|
401
405
|
const file = posix(relative(site.root, resolveDocumentFile(site, collection, path)));
|
|
402
406
|
const save = (await autosaves.get(file))?.value ?? null;
|
|
403
|
-
const read = await readDocument(site, collection, path, overlay(save ? [save] : []));
|
|
407
|
+
const read = await readDocument(site, collection, path, overlay(save ? [save] : []), { untitled: true });
|
|
404
408
|
return { ...read, mtimeMs: save?.updatedAt ?? 0, version: fileVersion(read.text), lastChange: await lastChangeOf(parts, file) };
|
|
405
409
|
}),
|
|
406
410
|
|
|
@@ -415,8 +419,15 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
415
419
|
const current = git.read(request.file);
|
|
416
420
|
// It exists: in the copy, or as a new document's autosave (M5a); a "gone" autosave is a deletion.
|
|
417
421
|
const missing = () => new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
|
|
418
|
-
const saved = await autosaves.
|
|
422
|
+
const [saved, changed] = await autosaves.getWithChange(request.file);
|
|
419
423
|
if (saved?.value.gone || (current === null && !saved)) throw missing();
|
|
424
|
+
// Discard changes: a tab that had the document when someone put it back to what's live
|
|
425
|
+
// can't write until it has let go of its copy (it's stale: its edits would come back).
|
|
426
|
+
const discard = changed?.discard;
|
|
427
|
+
if (discard && discard.tabs.includes(tab)) {
|
|
428
|
+
const title = saved?.value.text ? titleOf(request.file, saved.value.text) : titleOf(request.file, current);
|
|
429
|
+
throw new WriteRefused(409, `${displayName(discard.by)} discarded the unpublished changes to ${title}, so these edits weren't kept.`, { code: 'discarded', by: discard.by, at: discard.at });
|
|
430
|
+
}
|
|
420
431
|
|
|
421
432
|
const text = writeText(site, target.collection, request);
|
|
422
433
|
|
|
@@ -494,7 +505,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
494
505
|
if (await autosaves.putIf(existing, next)) break;
|
|
495
506
|
}
|
|
496
507
|
const version = fileVersion(text);
|
|
497
|
-
await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString() });
|
|
508
|
+
await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString(), ...autosaves.carryDiscard(changed) });
|
|
498
509
|
if (context?.release) await locks.release(request.file, tab, person);
|
|
499
510
|
sweep.poke();
|
|
500
511
|
return { ok: true, file: request.file, changed: text !== (existing?.value.text ?? current), mtimeMs: t, version };
|
package/src/live/editor.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live mode's editor server (M4 Phase 2): the live drafts store, the sweep, locks and
|
|
3
3
|
* presence, and the handlers behind `/_caracms/status`, `lock`, `publish`, `history`,
|
|
4
|
-
* `try-again` and `
|
|
4
|
+
* `try-again`, `notice` and `discard` (the routes in ../editor/routes/ are one line each).
|
|
5
|
+
*
|
|
6
|
+
* **Discard changes** (`discard`): one waiting document back to what's live. Its autosave,
|
|
7
|
+
* committed record and move records go and its files on `drafts` are committed as `main` has
|
|
8
|
+
* them (git.ts → `restoreDrafts`), all under the sweep's locks (sweep.ts → `exclusively`), so
|
|
9
|
+
* no tick commits the edit meanwhile. A moved document first moves back through the move
|
|
10
|
+
* engine (its links and redirects too), unredirected. Every other tab that had it open or
|
|
11
|
+
* was editing it is listed in its discard record (autosaves.ts): their writes are refused
|
|
12
|
+
* until they let go (`{ file, seen: true }`), and each other person who edited it or had it
|
|
13
|
+
* open gets a "discarded" notice. Refused while someone else is editing it now (a fresh
|
|
14
|
+
* lock), while a Publish runs, and for nothing waiting.
|
|
5
15
|
*
|
|
6
16
|
* One per process, beside the runtime (`liveEditor`), or made by tests
|
|
7
17
|
* (`createLiveEditor`) on a runtime from `createLiveRuntime` with the in-memory
|
|
@@ -32,8 +42,11 @@ import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
|
32
42
|
import { handleCreate, handleDelete } from '../editor/server/create.ts';
|
|
33
43
|
import { checkOrigin, confinePath, fileVersion, handleWrite, tabOf, WriteRefused } from '../editor/server/write.ts';
|
|
34
44
|
import { missingAltIn } from '../editor/server/media/alt.ts';
|
|
45
|
+
import { settingsWithRedirectsBack } from '../editor/server/move.ts';
|
|
46
|
+
import { parseAnyDocument } from '../document.ts';
|
|
35
47
|
import type {
|
|
36
48
|
ApiError,
|
|
49
|
+
DiscardResponse,
|
|
37
50
|
HistoryResponse,
|
|
38
51
|
LockRefusal,
|
|
39
52
|
LockResponse,
|
|
@@ -47,7 +60,7 @@ import type {
|
|
|
47
60
|
} from '../editor/types.ts';
|
|
48
61
|
import { Autosaves, splitMoves } from './autosaves.ts';
|
|
49
62
|
import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
|
|
50
|
-
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
|
|
63
|
+
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf, type DraftsLost } from './git.ts';
|
|
51
64
|
import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
|
|
52
65
|
import { parseRedirects, type Redirect } from '../redirects.ts';
|
|
53
66
|
import { Locks } from './locks.ts';
|
|
@@ -56,7 +69,9 @@ import type { LiveSettings } from './mode.ts';
|
|
|
56
69
|
import { Notices, type StoodNotice } from './notices.ts';
|
|
57
70
|
import { RedisUnavailableError } from './redis.ts';
|
|
58
71
|
import { liveRuntime, type LiveRuntime } from './runtime.ts';
|
|
59
|
-
import { Sweep, type Schedule } from './sweep.ts';
|
|
72
|
+
import { Sweep, SweepBusyError, type Schedule } from './sweep.ts';
|
|
73
|
+
import { readCappedText } from '../body.ts';
|
|
74
|
+
import { textProblems } from '../document-problems.ts';
|
|
60
75
|
|
|
61
76
|
type Handler = (request: Request, person: Person | null) => Promise<Response>;
|
|
62
77
|
|
|
@@ -67,8 +82,33 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
|
|
|
67
82
|
permission: 'GitHub refused a change',
|
|
68
83
|
expiring: "This site's GitHub token expires soon",
|
|
69
84
|
cloud: 'No GitHub access through CaraCMS Cloud',
|
|
85
|
+
// caracms-cloud adversarial review, finding 7: information, not a problem.
|
|
86
|
+
renamed: "This site's repo has a new name",
|
|
70
87
|
};
|
|
71
88
|
|
|
89
|
+
/** Finding 4: the save bar's sentence while draft commits keep failing. */
|
|
90
|
+
export const COMMIT_TROUBLE_MESSAGE = "Saved here, but not reaching GitHub. Ask your developer to check the site's GitHub access.";
|
|
91
|
+
|
|
92
|
+
/** Finding 5: the note's sentence once `drafts` had to be made again from `main`. */
|
|
93
|
+
export function draftsLostMessage(lost: DraftsLost): string {
|
|
94
|
+
const lastCommit = lost.commit ? `its last commit (${lost.commit.slice(0, 7)}) couldn't be found` : 'no record of its last commit was kept';
|
|
95
|
+
return `The drafts branch went missing on GitHub and ${lastCommit}, so it was made again from the live site. Changes saved since the last Publish are gone from the editor. Ask your developer to protect the drafts branch from deletion.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Force-push note (caracms-cloud adversarial review): `main` was rolled back past the last Publish. */
|
|
99
|
+
export const MAIN_ROLLED_BACK_TITLE = 'The live site was rolled back on GitHub';
|
|
100
|
+
export const MAIN_ROLLED_BACK_MESSAGE =
|
|
101
|
+
"The changes waiting here include what was rolled back, and publishing will put it back. Discard what you don't want first: each waiting document has Discard… in the Publish list.";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A Redis outage before a Publish's refs move (finding 6): nothing went out, and nothing is
|
|
105
|
+
* held in the browser either (that's the write route's sentence, `unavailable`).
|
|
106
|
+
*/
|
|
107
|
+
export function publishUnavailable(error: unknown): WriteRefused {
|
|
108
|
+
const what = error instanceof Error ? error.message : String(error);
|
|
109
|
+
return new WriteRefused(503, `The server can't reach its store right now, so it couldn't publish. Your changes are still waiting: try again in a minute (${what}).`, { code: 'unavailable' });
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
export interface LiveEditor {
|
|
73
113
|
readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
|
|
74
114
|
readonly autosaves: Autosaves;
|
|
@@ -85,6 +125,8 @@ export interface LiveEditor {
|
|
|
85
125
|
history: Handler;
|
|
86
126
|
tryAgain: Handler;
|
|
87
127
|
notice: Handler;
|
|
128
|
+
/** Discard changes: POST /_caracms/discard `{ file }` (types.ts → DiscardRequest). */
|
|
129
|
+
discard: Handler;
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
export interface LiveEditorOptions {
|
|
@@ -99,8 +141,8 @@ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Cont
|
|
|
99
141
|
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: HEADERS });
|
|
100
142
|
const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
|
|
101
143
|
|
|
102
|
-
/** A notice id: `kept:<file>:<commit>` or `
|
|
103
|
-
const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
|
|
144
|
+
/** A notice id: `kept:<file>:<commit>`, `stood:<commit>:<file>:<email>` or `discarded:<at>:<file>:<email>`, no spaces or controls, capped. */
|
|
145
|
+
const NOTICE_ID = /^(kept|stood|discarded):[^\s\p{C}]+$/u;
|
|
104
146
|
const NOTICE_ID_MAX = 512;
|
|
105
147
|
|
|
106
148
|
/** Map a site file to its document (collection and path), or nulls. */
|
|
@@ -152,9 +194,9 @@ export async function createLiveEditor(
|
|
|
152
194
|
// hook above in place, so a merge from main commits every pending autosave first.
|
|
153
195
|
await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
|
|
154
196
|
|
|
155
|
-
/** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
|
|
197
|
+
/** Common gate: a person, the same origin for anything but GET, Redis outages as 503 (`outage`: that route's sentence for one). */
|
|
156
198
|
const handler =
|
|
157
|
-
(methods: string[], fn: (request: Request, person: Person) => Promise<Response
|
|
199
|
+
(methods: string[], fn: (request: Request, person: Person) => Promise<Response>, outage: (error: unknown) => WriteRefused = unavailable): Handler =>
|
|
158
200
|
async (request, person) => {
|
|
159
201
|
try {
|
|
160
202
|
if (!methods.includes(request.method)) {
|
|
@@ -167,7 +209,7 @@ export async function createLiveEditor(
|
|
|
167
209
|
if (request.method !== 'GET') checkOrigin(request);
|
|
168
210
|
return await fn(request, person);
|
|
169
211
|
} catch (error) {
|
|
170
|
-
const refused = error instanceof RedisUnavailableError ?
|
|
212
|
+
const refused = error instanceof RedisUnavailableError ? outage(error) : error;
|
|
171
213
|
if (refused instanceof WriteRefused) return json(refused.status, { error: refused.message, ...refused.body });
|
|
172
214
|
log(`${new URL(request.url).pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
173
215
|
return json(502, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` } satisfies ApiError);
|
|
@@ -175,8 +217,9 @@ export async function createLiveEditor(
|
|
|
175
217
|
};
|
|
176
218
|
|
|
177
219
|
const readBody = async (request: Request): Promise<Record<string, unknown>> => {
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
// caracms-cloud adversarial review, finding 10: 16 KB counted as it's read, never the whole body first.
|
|
221
|
+
const raw = await readCappedText(request, 16_384);
|
|
222
|
+
if (raw === null) throw new WriteRefused(413, 'That body is too large.');
|
|
180
223
|
try {
|
|
181
224
|
const body = JSON.parse(raw || '{}') as unknown;
|
|
182
225
|
if (body && typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>;
|
|
@@ -320,7 +363,11 @@ export async function createLiveEditor(
|
|
|
320
363
|
// autosave's text, else the copy's). A deleted document has none.
|
|
321
364
|
const textNow = new Map(saves.map((a) => [a.file, a.text]));
|
|
322
365
|
for (const w of waiting.values()) {
|
|
323
|
-
|
|
366
|
+
const text = textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file);
|
|
367
|
+
w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, text);
|
|
368
|
+
// caracms-cloud adversarial review, finding 15: what Publish will leave waiting, by the same rule (git.ts → heldBack).
|
|
369
|
+
const [problem] = w.change !== 'deleted' && w.collection !== null && text !== null ? textProblems(text) : [];
|
|
370
|
+
if (problem) w.heldBack = problem.reason;
|
|
324
371
|
}
|
|
325
372
|
// Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
|
|
326
373
|
// Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
|
|
@@ -359,9 +406,16 @@ export async function createLiveEditor(
|
|
|
359
406
|
if (save ? save.gone : git.read(file) === null) {
|
|
360
407
|
deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
|
|
361
408
|
}
|
|
362
|
-
|
|
409
|
+
// Discard changes: this tab had it when someone put it back to what's live (its copy is stale).
|
|
410
|
+
const discard = record?.discard;
|
|
411
|
+
const discarded = discard && tab && discard.tabs.includes(tab) ? { by: discard.by, at: discard.at } : null;
|
|
412
|
+
open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted, discarded };
|
|
363
413
|
}
|
|
364
414
|
const access = git.backend.accessProblem();
|
|
415
|
+
// Finding 4: only while something is waiting to reach GitHub.
|
|
416
|
+
const trouble = saves.length ? git.commitTrouble() : null;
|
|
417
|
+
const lost = git.draftsLost();
|
|
418
|
+
const rolledBack = git.mainRolledBack();
|
|
365
419
|
const body: StatusResponse = {
|
|
366
420
|
mode: 'live',
|
|
367
421
|
you: person,
|
|
@@ -373,11 +427,14 @@ export async function createLiveEditor(
|
|
|
373
427
|
locks: lockViews,
|
|
374
428
|
presence,
|
|
375
429
|
notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
|
|
376
|
-
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
|
|
430
|
+
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: git.publishRecordOr(LiveGit.publishRecordFrom(snap.string(git.publishRecordKey))) }),
|
|
377
431
|
// Runs-anywhere Decision 5: while it holds, not dismissable.
|
|
378
432
|
githubAccess: access
|
|
379
433
|
? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
|
|
380
434
|
: null,
|
|
435
|
+
commitTrouble: trouble ? { since: new Date(trouble.since).toISOString(), failures: trouble.failures, message: COMMIT_TROUBLE_MESSAGE } : null,
|
|
436
|
+
draftsLost: lost ? { title: 'Unpublished changes were lost', message: draftsLostMessage(lost), commit: lost.commit, at: lost.at } : null,
|
|
437
|
+
mainRolledBack: rolledBack ? { title: MAIN_ROLLED_BACK_TITLE, message: MAIN_ROLLED_BACK_MESSAGE, publish: rolledBack.publish, main: rolledBack.main, at: rolledBack.at } : null,
|
|
381
438
|
// M5a Decision 10: from the committed copy, never an autosave.
|
|
382
439
|
brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
|
|
383
440
|
// Arrange pages (Decision 18): from the autosaves already in the snapshot, else the copy; no Redis command of its own.
|
|
@@ -428,7 +485,10 @@ export async function createLiveEditor(
|
|
|
428
485
|
const publish = handler(['POST'], async (_request, person) => {
|
|
429
486
|
const before = await locks.presence();
|
|
430
487
|
const work = git.publish(person).then(async (result) => {
|
|
431
|
-
|
|
488
|
+
// Finding 6: the notices are kept in Redis, so with Redis gone they can't be sent; the Publish happened all the same.
|
|
489
|
+
await stoodNotices(result, before, person).catch((error: unknown) => {
|
|
490
|
+
log(`publish: the "published as it stood" notices weren't sent (${error instanceof Error ? error.message : String(error)})`);
|
|
491
|
+
});
|
|
432
492
|
return result;
|
|
433
493
|
});
|
|
434
494
|
// Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
|
|
@@ -444,8 +504,17 @@ export async function createLiveEditor(
|
|
|
444
504
|
work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
|
|
445
505
|
return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
|
|
446
506
|
}
|
|
507
|
+
if (result.kind === 'published') {
|
|
508
|
+
// Finding 6: the record as made, not as Redis has it (it may not have taken it); the lock read is the only Redis call, and its failure isn't the Publish's.
|
|
509
|
+
const running = await redis.get(git.publishLockKey).then(
|
|
510
|
+
(v) => v !== null,
|
|
511
|
+
() => false,
|
|
512
|
+
);
|
|
513
|
+
return json(200, { result: result.kind, publish: await publishView({ running, record: result.record }), held: result.held } satisfies PublishResponse);
|
|
514
|
+
}
|
|
515
|
+
if (result.kind === 'held-back') return json(200, { result: result.kind, publish: await publishView(), held: result.held } satisfies PublishResponse);
|
|
447
516
|
return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
|
|
448
|
-
});
|
|
517
|
+
}, publishUnavailable);
|
|
449
518
|
|
|
450
519
|
/** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
|
|
451
520
|
const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
|
|
@@ -493,7 +562,154 @@ export async function createLiveEditor(
|
|
|
493
562
|
return json(200, { ok: true });
|
|
494
563
|
});
|
|
495
564
|
|
|
496
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Discard changes (see the header). `{ file }`: the document's file now (a moved one's new
|
|
567
|
+
* file). `{ file, seen: true }`: this tab has let go of its copy of a discarded document.
|
|
568
|
+
*/
|
|
569
|
+
const discard = handler(['POST'], async (request, person) => {
|
|
570
|
+
const tab = tabOf(request);
|
|
571
|
+
if (!tab) throw new WriteRefused(400, 'Discard requests name their tab (X-Caracms-Tab).', { code: 'no-tab' });
|
|
572
|
+
const body = await readBody(request);
|
|
573
|
+
const extra = Object.keys(body).filter((k) => k !== 'file' && k !== 'seen');
|
|
574
|
+
if (extra.length || typeof body.file !== 'string' || (body.seen !== undefined && body.seen !== true)) {
|
|
575
|
+
throw new WriteRefused(400, 'Malformed body: send { file } (or { file, seen: true }).', { code: 'bad-request' });
|
|
576
|
+
}
|
|
577
|
+
const file = body.file;
|
|
578
|
+
confinePath(site, file);
|
|
579
|
+
if (body.seen === true) {
|
|
580
|
+
await autosaves.ackDiscard(file, tab);
|
|
581
|
+
return json(200, { ok: true });
|
|
582
|
+
}
|
|
583
|
+
if ((await redis.get(git.publishLockKey)) !== null) throw new WriteRefused(409, "A Publish is running. Try again once it's done.", { code: 'publishing' });
|
|
584
|
+
|
|
585
|
+
// What's waiting for it: an autosave, a commit on drafts that main hasn't got, or a move to it.
|
|
586
|
+
const save = (await autosaves.get(file))?.value ?? null;
|
|
587
|
+
const textNow = save ? save.text : git.read(file);
|
|
588
|
+
const fromGit = git.waiting().find((w) => w.file === file) ?? null;
|
|
589
|
+
const moves = await autosaves.moves();
|
|
590
|
+
const from = [...moves].find(([, to]) => to === file)?.[0] ?? null;
|
|
591
|
+
const change = git.changeOf(file, textNow);
|
|
592
|
+
const title = save?.gone ? save.gone.title : textNow !== null ? titleOf(file, textNow) : git.titleFor(file);
|
|
593
|
+
if (!change || (!save && !fromGit && !from)) {
|
|
594
|
+
if (!from && (await autosaves.addresses()).has(file)) {
|
|
595
|
+
throw new WriteRefused(409, `${title} moved with the page that holds it. Discard that page's changes to put it back.`, { code: 'held-move' });
|
|
596
|
+
}
|
|
597
|
+
throw new WriteRefused(404, `${title} has no unpublished changes.`, { code: 'not-waiting' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Someone else editing it right now (a fresh lock, not one held only until their autosave is committed).
|
|
601
|
+
const own = [file, ...(from ? [from] : [])];
|
|
602
|
+
for (const f of own) {
|
|
603
|
+
const held = await locks.read(f);
|
|
604
|
+
if (held && !sameEmail(held.lock.person, person) && held.lock.releasing === null && now() - held.lock.beat < timings.lockLapseMs) {
|
|
605
|
+
throw new WriteRefused(409, `${displayName(held.lock.person)} is editing ${title} right now, so its changes can't be discarded yet.`, { code: 'locked' });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const t0 = now();
|
|
610
|
+
let movedBack: DiscardResponse['movedBack'] = null;
|
|
611
|
+
if (from && git.mainHas(from)) {
|
|
612
|
+
// Back to its old address first, through the move engine (its links, the redirects, what
|
|
613
|
+
// moved with it), with no redirect. A swap (it took a live document's place) isn't undone here.
|
|
614
|
+
const there = docOf(site, file);
|
|
615
|
+
const back = docOf(site, from);
|
|
616
|
+
if (git.mainHas(file)) throw new WriteRefused(409, `${title} took the place of a page that's live, so it can't be discarded on its own.`, { code: 'swap' });
|
|
617
|
+
if (!there.collection || !there.path || !back.path || back.collection !== there.collection) {
|
|
618
|
+
throw new WriteRefused(409, `${title} can't go back to its old address from here.`, { code: 'swap' });
|
|
619
|
+
}
|
|
620
|
+
// Any lock someone left on it (only held until their autosave is committed) goes, so the move can take it.
|
|
621
|
+
for (const f of own) {
|
|
622
|
+
const held = await locks.read(f);
|
|
623
|
+
if (held && held.lock.tab !== tab) await locks.drop(held, f);
|
|
624
|
+
}
|
|
625
|
+
const context = { person, tab, release: false, seq: null };
|
|
626
|
+
const moved = await store.move({ collection: there.collection, path: there.path, to: back.path, redirect: false }, context);
|
|
627
|
+
// A redirect the move took out of Site Settings (from the address it took) comes back, as it is live.
|
|
628
|
+
const vacated = moved.moved.filter((m) => m.fromUrl !== m.toUrl).map((m) => m.fromUrl);
|
|
629
|
+
const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE);
|
|
630
|
+
const restored = settingsWithRedirectsBack(site, current, (await git.readMain([SITE_SETTINGS_FILE])).get(SITE_SETTINGS_FILE) ?? null, vacated);
|
|
631
|
+
if (restored !== null && current !== null) {
|
|
632
|
+
const settings = site.collections.settings ?? Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
633
|
+
if (settings) {
|
|
634
|
+
await store.write({ file: SITE_SETTINGS_FILE, base: current, document: parseAnyDocument(JSON.parse(restored), site, settings) }, context);
|
|
635
|
+
await locks.release(SITE_SETTINGS_FILE, tab, person);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
await locks.release(from, tab, person);
|
|
639
|
+
movedBack = { collection: back.collection, path: back.path, file: from };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const result = await sweep
|
|
643
|
+
.exclusively(async () => {
|
|
644
|
+
// The document's files, and any file the move back left exactly as main has it (a link put back).
|
|
645
|
+
const files = new Set(own);
|
|
646
|
+
if (movedBack) {
|
|
647
|
+
const mine = (await autosaves.all()).map((r) => r.value).filter((a) => a.tab === tab && sameEmail(a.editor, person) && a.updatedAt >= t0 && !files.has(a.file));
|
|
648
|
+
const main = await git.readMain(mine.map((a) => a.file));
|
|
649
|
+
for (const a of mine) if (a.text === (main.get(a.file) ?? null)) files.add(a.file);
|
|
650
|
+
}
|
|
651
|
+
const list = [...files];
|
|
652
|
+
// Every tab that has it or had edits in it, but this one: their copies are stale from now.
|
|
653
|
+
const tabs = new Set<string>();
|
|
654
|
+
const people = new Map<string, Person>();
|
|
655
|
+
const note = (p: Person) => {
|
|
656
|
+
if (!sameEmail(p, person)) people.set(p.email.toLowerCase(), p);
|
|
657
|
+
};
|
|
658
|
+
for (const e of fromGit?.editors ?? []) note(e.person);
|
|
659
|
+
for (const f of list) {
|
|
660
|
+
const a = (await autosaves.get(f))?.value;
|
|
661
|
+
if (a) {
|
|
662
|
+
tabs.add(a.tab);
|
|
663
|
+
note(a.editor);
|
|
664
|
+
}
|
|
665
|
+
const c = await autosaves.committed(f);
|
|
666
|
+
if (c) tabs.add(c.tab);
|
|
667
|
+
const l = await locks.read(f);
|
|
668
|
+
if (l) tabs.add(l.lock.tab);
|
|
669
|
+
for (const p of await locks.tabsOn(f)) {
|
|
670
|
+
tabs.add(p.tab);
|
|
671
|
+
note(p.person);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
tabs.delete(tab);
|
|
675
|
+
const at = new Date(now()).toISOString();
|
|
676
|
+
const titles = [title];
|
|
677
|
+
const restored = await git.restoreDrafts(list, titles, person);
|
|
678
|
+
// Who changed it last, as it reads now, with the discard and the tabs that had it.
|
|
679
|
+
const changeOf = (f: string) => {
|
|
680
|
+
const text = restored.texts.get(f) ?? null;
|
|
681
|
+
const discard = { by: person, at, tabs: [...tabs] };
|
|
682
|
+
return text === null ? { by: person, version: '', at, gone: true, discard } : { by: person, version: fileVersion(text), at, discard };
|
|
683
|
+
};
|
|
684
|
+
for (const f of list) await autosaves.setChanged(f, changeOf(f));
|
|
685
|
+
for (const f of list) {
|
|
686
|
+
for (const gone of await autosaves.removeAll(f)) if (gone.tab !== tab) tabs.add(gone.tab);
|
|
687
|
+
await autosaves.forgetCommitted(f);
|
|
688
|
+
}
|
|
689
|
+
await autosaves.forgetMovesOf(list);
|
|
690
|
+
for (const f of list) {
|
|
691
|
+
await autosaves.setChanged(f, changeOf(f)); // any tab whose autosave was removed just now too
|
|
692
|
+
// This tab's lock lets go; one left by a tab that's gone is dropped; a live one (your other tab) stays, and that tab is told.
|
|
693
|
+
const l = await locks.read(f);
|
|
694
|
+
const fresh = !!l && l.lock.releasing === null && now() - l.lock.beat < timings.lockLapseMs;
|
|
695
|
+
if (l && l.lock.tab === tab) await locks.release(f, tab, person);
|
|
696
|
+
else if (l && !fresh) await locks.drop(l, f);
|
|
697
|
+
}
|
|
698
|
+
// "Aoife discarded the unpublished changes to About", for everyone else who had it.
|
|
699
|
+
await noticeStore
|
|
700
|
+
.addStood([...people.values()].map((p) => ({ id: `discarded:${at}:${file}:${p.email.toLowerCase()}`, for: p.email.toLowerCase(), file: movedBack?.file ?? file, title, by: person, at, kind: 'discarded' as const })))
|
|
701
|
+
.catch((error: unknown) => log(`discard: the notices weren't left (${error instanceof Error ? error.message : String(error)})`));
|
|
702
|
+
log(`${person.email} discarded the unpublished changes to ${list.join(', ')}`);
|
|
703
|
+
return { files: list };
|
|
704
|
+
})
|
|
705
|
+
.catch((error: unknown) => {
|
|
706
|
+
if (error instanceof SweepBusyError) throw new WriteRefused(409, 'The server is busy saving changes. Try again in a moment.', { code: 'busy' });
|
|
707
|
+
throw error;
|
|
708
|
+
});
|
|
709
|
+
return json(200, { ok: true, file, change, files: result.files, movedBack } satisfies DiscardResponse);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice, discard };
|
|
497
713
|
}
|
|
498
714
|
|
|
499
715
|
const SHARED = Symbol.for('caracms.liveEditor');
|