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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { ReactNode } from 'react';
|
|
14
14
|
import { useEditor, useStore } from '../context.tsx';
|
|
15
15
|
import { Icon } from '../shell/Icons.tsx';
|
|
16
|
-
import { docId } from '../../types.ts';
|
|
16
|
+
import { docId, opensInEditor } from '../../types.ts';
|
|
17
17
|
import { useTooLong } from '../nav/DocRow.tsx';
|
|
18
18
|
import { DocMarksView } from '../live/marks.tsx';
|
|
19
19
|
import { OfflineExtras } from '../live/session.tsx';
|
|
@@ -87,6 +87,9 @@ export function GlobalNotes() {
|
|
|
87
87
|
return (
|
|
88
88
|
<>
|
|
89
89
|
<GitHubAccessNote />
|
|
90
|
+
<DraftsLostNote />
|
|
91
|
+
<RolledBackNote />
|
|
92
|
+
<CommitTroubleNote />
|
|
90
93
|
<KeepNotes />
|
|
91
94
|
</>
|
|
92
95
|
);
|
|
@@ -101,10 +104,17 @@ function GitHubAccessNote() {
|
|
|
101
104
|
const editor = useEditor();
|
|
102
105
|
const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
|
|
103
106
|
if (editor.mode !== 'live' || !access) return null;
|
|
107
|
+
// caracms-cloud adversarial review, finding 7: a rename taken in its stride is information, in the plain note.
|
|
108
|
+
const renamed = access.kind === 'renamed';
|
|
104
109
|
const expiring = access.kind === 'expiring';
|
|
105
110
|
return (
|
|
106
|
-
<div
|
|
107
|
-
|
|
111
|
+
<div
|
|
112
|
+
className={renamed ? 'note' : `note ${expiring ? 'warn' : 'danger'}`}
|
|
113
|
+
role={renamed || expiring ? 'status' : 'alert'}
|
|
114
|
+
data-note="github-access"
|
|
115
|
+
data-kind={access.kind}
|
|
116
|
+
>
|
|
117
|
+
<Icon name={renamed ? 'check' : 'warn'} />
|
|
108
118
|
<div>
|
|
109
119
|
<b>{access.title}</b>
|
|
110
120
|
<p>{withLink(access.message, access.link ?? null)}</p>
|
|
@@ -113,6 +123,59 @@ function GitHubAccessNote() {
|
|
|
113
123
|
);
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
/**
|
|
127
|
+
* caracms-cloud adversarial review, finding 4: edits are kept on the server but its commits
|
|
128
|
+
* to GitHub keep failing (the bar says so too). Left out while the access note shows a
|
|
129
|
+
* problem: that one already says what's wrong and who fixes it (finding 7's rename says neither).
|
|
130
|
+
*/
|
|
131
|
+
function CommitTroubleNote() {
|
|
132
|
+
const editor = useEditor();
|
|
133
|
+
const trouble = useStore(editor.liveState, (s) => s.status?.commitTrouble ?? null);
|
|
134
|
+
const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
|
|
135
|
+
if (editor.mode !== 'live' || !trouble || (access && access.kind !== 'renamed')) return null;
|
|
136
|
+
return (
|
|
137
|
+
<div className="note danger" role="alert" data-note="commit-trouble">
|
|
138
|
+
<Icon name="warn" />
|
|
139
|
+
<div>
|
|
140
|
+
<b>Changes aren't reaching GitHub</b>
|
|
141
|
+
<p>{trouble.message}</p>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Finding 5: the `drafts` branch went missing and was made again from `main`; the sentence is the server's. */
|
|
148
|
+
function DraftsLostNote() {
|
|
149
|
+
const editor = useEditor();
|
|
150
|
+
const lost = useStore(editor.liveState, (s) => s.status?.draftsLost ?? null);
|
|
151
|
+
if (editor.mode !== 'live' || !lost) return null;
|
|
152
|
+
return (
|
|
153
|
+
<div className="note danger" role="alert" data-note="drafts-lost">
|
|
154
|
+
<Icon name="warn" />
|
|
155
|
+
<div>
|
|
156
|
+
<b>{lost.title}</b>
|
|
157
|
+
<p>{lost.message}</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** caracms-cloud adversarial review, force-push note: `main` was rolled back past the last Publish; the sentence is the server's. */
|
|
164
|
+
function RolledBackNote() {
|
|
165
|
+
const editor = useEditor();
|
|
166
|
+
const rolled = useStore(editor.liveState, (s) => s.status?.mainRolledBack ?? null);
|
|
167
|
+
if (editor.mode !== 'live' || !rolled) return null;
|
|
168
|
+
return (
|
|
169
|
+
<div className="note warn" role="alert" data-note="main-rolled-back">
|
|
170
|
+
<Icon name="warn" />
|
|
171
|
+
<div>
|
|
172
|
+
<b>{rolled.title}</b>
|
|
173
|
+
<p>{rolled.message}</p>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
116
179
|
/** The sentence as it came, with `link` as a link where it names it (or after it); the words are never changed. */
|
|
117
180
|
function withLink(message: string, link: string | null) {
|
|
118
181
|
if (!link) return message;
|
|
@@ -187,7 +250,7 @@ export function RecentStrip() {
|
|
|
187
250
|
const current = useStore(editor.doc.state, (s) => docId(s.ref));
|
|
188
251
|
const items = recent
|
|
189
252
|
.map((id) => documents.find((d) => docId(d) === id))
|
|
190
|
-
.filter((d): d is NonNullable<typeof d> => !!d &&
|
|
253
|
+
.filter((d): d is NonNullable<typeof d> => !!d && opensInEditor(d))
|
|
191
254
|
.slice(0, 5);
|
|
192
255
|
return (
|
|
193
256
|
<div className="side-foot">
|
|
@@ -14,7 +14,7 @@ import { useEditor, useStore } from '../context.tsx';
|
|
|
14
14
|
import { Icon } from '../shell/Icons.tsx';
|
|
15
15
|
import { DocRow } from '../nav/DocRow.tsx';
|
|
16
16
|
import { parentLabel, searchDocuments } from '../nav/search.ts';
|
|
17
|
-
import { docId, type ListedDocument } from '../../types.ts';
|
|
17
|
+
import { docId, opensInEditor, type ListedDocument } from '../../types.ts';
|
|
18
18
|
|
|
19
19
|
export const SWITCHER_READY = true;
|
|
20
20
|
|
|
@@ -51,7 +51,7 @@ export function Switcher() {
|
|
|
51
51
|
const out: Section[] = [];
|
|
52
52
|
const recentDocs = recent
|
|
53
53
|
.map((id) => documents.find((d) => docId(d) === id))
|
|
54
|
-
.filter((d): d is ListedDocument => !!d &&
|
|
54
|
+
.filter((d): d is ListedDocument => !!d && opensInEditor(d))
|
|
55
55
|
.slice(0, 3);
|
|
56
56
|
if (recentDocs.length) out.push({ key: 'recent', heading: 'Recent', items: recentDocs.map((d) => ({ doc: d, crumb: crumbOf(d) })), more: null });
|
|
57
57
|
for (const c of editor.site.collections) {
|
|
@@ -102,8 +102,8 @@ export function Switcher() {
|
|
|
102
102
|
event.preventDefault();
|
|
103
103
|
const hit = flat[activeIndex];
|
|
104
104
|
if (!hit) return;
|
|
105
|
-
if (hit.doc
|
|
106
|
-
editor.announce(`${hit.doc.label} can't be opened. ${hit.doc.warning
|
|
105
|
+
if (!opensInEditor(hit.doc)) {
|
|
106
|
+
editor.announce(`${hit.doc.label} can't be opened. ${hit.doc.warning?.message ?? ''}`);
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
editor.setPop(null);
|
|
@@ -132,6 +132,8 @@ function Keep() {
|
|
|
132
132
|
const error = useStore(editor.keep, (k) => k.error);
|
|
133
133
|
const unsent = useStore(editor.keep, (k) => k.unsent ?? 0);
|
|
134
134
|
const signedOut = useStore(editor.liveState, (s) => !!s.signedOut);
|
|
135
|
+
// caracms-cloud adversarial review, finding 4: the server has the edits, but its commits to GitHub keep failing.
|
|
136
|
+
const trouble = useStore(editor.liveState, (s) => s.status?.commitTrouble ?? null);
|
|
135
137
|
if (state === 'signedout' || (signedOut && state !== 'offline')) {
|
|
136
138
|
return (
|
|
137
139
|
<span className="keep loud" role="status" data-keep="signedout">
|
|
@@ -174,6 +176,15 @@ function Keep() {
|
|
|
174
176
|
</span>
|
|
175
177
|
);
|
|
176
178
|
}
|
|
179
|
+
if (trouble && editor.mode === 'live') {
|
|
180
|
+
// Not Kept: kept in Redis, but not reaching GitHub. The pane's note says who can fix it.
|
|
181
|
+
return (
|
|
182
|
+
<span className="keep loud" role="status" data-keep="not-reaching" title={trouble.message}>
|
|
183
|
+
<Icon name="warn" size="sm" />
|
|
184
|
+
Saved here, not reaching GitHub
|
|
185
|
+
</span>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
177
188
|
return (
|
|
178
189
|
<span className="keep" data-keep="kept">
|
|
179
190
|
<Icon name="check" size="sm" />
|
|
@@ -49,7 +49,12 @@
|
|
|
49
49
|
* **Another editor changed it since, or the server can't tell who did (`known:
|
|
50
50
|
* false`): not re-sent**, held as a `conflict` until Use mine (`useMine`) or
|
|
51
51
|
* Discard mine (`discard`).
|
|
52
|
-
*
|
|
52
|
+
* Discard changes: when the server says someone put the document back to what's live
|
|
53
|
+
* (`lastChange.discarded`), edits stored from before then (`at`, when they were last changed)
|
|
54
|
+
* are dropped, not re-sent, so a discard doesn't come undone from a browser that missed it.
|
|
55
|
+
* A write the server refuses as `discarded` (this tab had the document when it was put back)
|
|
56
|
+
* is dropped the same way, and `onDiscarded` tells the editor to read it again.
|
|
57
|
+
* Versions are compared for equality, never as times; only the discard rule compares a time.
|
|
53
58
|
*/
|
|
54
59
|
import { documentKind, normaliseAnyDocument, type AnyDocument } from '../../../document.ts';
|
|
55
60
|
import { applyDocumentEdits } from '../../../serialize.ts';
|
|
@@ -106,6 +111,8 @@ export interface StoredUnsent {
|
|
|
106
111
|
document: AnyDocument;
|
|
107
112
|
/** The version of the file these edits were made against (see above). */
|
|
108
113
|
fileVersion: string;
|
|
114
|
+
/** ms since the epoch: the newest change in them (the discard rule). Absent in copies stored before it was added. */
|
|
115
|
+
at?: number;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
/** The parts of Storage the keeper uses (localStorage, or a fake in tests). */
|
|
@@ -162,6 +169,8 @@ export interface KeeperOptions {
|
|
|
162
169
|
keepCap?: number;
|
|
163
170
|
/** A write for this file was Kept (the server has it, and the write took or kept the lock). */
|
|
164
171
|
onKept?: (file: string) => void;
|
|
172
|
+
/** A write got 409 `discarded`: someone put the document back to what's live; this tab's edits in it are dropped. */
|
|
173
|
+
onDiscarded?: (file: string, body: { error?: string; by?: Person; at?: string }) => void;
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
/** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
|
|
@@ -307,7 +316,7 @@ export function holdsEdits(text: string, stored: Pick<StoredUnsent, 'base' | 'do
|
|
|
307
316
|
* `{ conflict: by }` (another editor changed the document since, Decision 11).
|
|
308
317
|
*/
|
|
309
318
|
export function verdict(
|
|
310
|
-
stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion'>,
|
|
319
|
+
stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion' | 'at'>,
|
|
311
320
|
file: FileNow,
|
|
312
321
|
site: KeeperSite,
|
|
313
322
|
you: Person | null,
|
|
@@ -315,6 +324,9 @@ export function verdict(
|
|
|
315
324
|
if (stored.fileVersion === file.version) return 'send'; // nothing has written it since
|
|
316
325
|
if (holdsEdits(file.text, stored, site)) return 'drop';
|
|
317
326
|
const last = file.lastChange;
|
|
327
|
+
// Discard changes: edits from before someone put it back to what's live are what was discarded.
|
|
328
|
+
const discarded = last?.discarded;
|
|
329
|
+
if (discarded && !(typeof stored.at === 'number' && stored.at > Date.parse(discarded.at))) return 'drop';
|
|
318
330
|
if (!last) return 'send'; // local mode (M3): the last write wins
|
|
319
331
|
if (last.known === false) return { conflict: null }; // the server can't tell who changed it: never re-sent over unknown work
|
|
320
332
|
if (!last.by) return 'send'; // only merges from main are known
|
|
@@ -421,7 +433,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
421
433
|
return r;
|
|
422
434
|
};
|
|
423
435
|
const stash = (r: Record) => {
|
|
424
|
-
if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
|
|
436
|
+
if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion, ...(r.lastChange ? { at: r.lastChange } : {}) });
|
|
425
437
|
};
|
|
426
438
|
/** Decision 10 (live): ms until the change waiting goes, once the document is held: the delay after the last change, capped from the first unsent one. */
|
|
427
439
|
const due = (r: Record) => {
|
|
@@ -523,6 +535,16 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
523
535
|
r.by = refusal.lastChange?.by ?? refusal.lock?.person ?? null;
|
|
524
536
|
log(`${r.file} is locked: ${message}`);
|
|
525
537
|
options.onLocked?.(r.file, refusal);
|
|
538
|
+
} else if (options.tab && error.status === 409 && code === 'discarded') {
|
|
539
|
+
// Someone put the document back to what's live while this tab had it: these edits were discarded.
|
|
540
|
+
r.written = r.revision;
|
|
541
|
+
r.document = null;
|
|
542
|
+
r.failure = null;
|
|
543
|
+
r.refusal = null;
|
|
544
|
+
r.by = null;
|
|
545
|
+
storage.remove(r.id);
|
|
546
|
+
log(`${r.file}: ${message}`);
|
|
547
|
+
options.onDiscarded?.(r.file, error.body as { error?: string; by?: Person; at?: string });
|
|
526
548
|
} else {
|
|
527
549
|
r.failure = 'refused';
|
|
528
550
|
r.refusal = message;
|
|
@@ -562,6 +584,8 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
562
584
|
r.document = stored.document;
|
|
563
585
|
r.fileVersion = stored.fileVersion;
|
|
564
586
|
r.revision += 1;
|
|
587
|
+
// The discard rule reads when they were made, not when they were adopted.
|
|
588
|
+
if (typeof stored.at === 'number') r.lastChange = stored.at;
|
|
565
589
|
return r;
|
|
566
590
|
}
|
|
567
591
|
|
|
@@ -727,7 +751,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
727
751
|
recheck(id, file) {
|
|
728
752
|
const r = records.get(id);
|
|
729
753
|
if (!r || !dirty(r) || !r.document) return null;
|
|
730
|
-
const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion }, file, options.site, options.you?.() ?? null);
|
|
754
|
+
const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion, at: r.lastChange || undefined }, file, options.site, options.you?.() ?? null);
|
|
731
755
|
if (v === 'drop') {
|
|
732
756
|
r.written = r.revision;
|
|
733
757
|
r.failure = null;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - **Signed out**: any 401 (`SignedOutError`) sets `signedOut`; the next poll that
|
|
19
19
|
* answers 200 clears it (signing in from another tab brings the session back).
|
|
20
20
|
*/
|
|
21
|
-
import { ENDPOINTS, TAB_HEADER, type HistoryResponse, type LiveProps, type LockRefusal, type LockResponse, type PublishResponse, type SignedOutError, type StatusResponse, type TryAgainResponse } from '../../types.ts';
|
|
21
|
+
import { ENDPOINTS, TAB_HEADER, type DiscardRefusal, type DiscardResponse, type HistoryResponse, type LiveProps, type LockRefusal, type LockResponse, type PublishResponse, type SignedOutError, type StatusResponse, type TryAgainResponse } from '../../types.ts';
|
|
22
22
|
import { createStore, type Store } from './store.ts';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -65,6 +65,10 @@ export interface LiveSession {
|
|
|
65
65
|
take(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false; refusal: LockRefusal | null }>;
|
|
66
66
|
/** Take over: move anyone's lock on the document to this tab (their autosave is committed first). */
|
|
67
67
|
takeOver(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false }>;
|
|
68
|
+
/** Discard changes: put one waiting document back to what's live. A refusal carries the server's sentence. */
|
|
69
|
+
discard(file: string): Promise<{ ok: true; body: DiscardResponse } | { ok: false; error: string; code: DiscardRefusal['code'] | null }>;
|
|
70
|
+
/** This tab has let go of its copy of a document someone discarded (`open.discarded`). */
|
|
71
|
+
discardSeen(file: string): Promise<void>;
|
|
68
72
|
/** Someone typed, clicked or scrolled in this tab: each poll says how long ago (the lock's idle rule). */
|
|
69
73
|
active(): void;
|
|
70
74
|
signOut(): Promise<string>;
|
|
@@ -269,6 +273,19 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
269
273
|
state.set((s) => (s.status ? { ...s, status: { ...s.status, notices: s.status.notices.filter((n) => n.id !== id) } } : s));
|
|
270
274
|
await call(ENDPOINTS.notice, { method: 'POST', body: { id } });
|
|
271
275
|
},
|
|
276
|
+
async discard(file) {
|
|
277
|
+
const answer = await call<DiscardResponse | DiscardRefusal>(ENDPOINTS.discard, { method: 'POST', body: { file } });
|
|
278
|
+
void poll();
|
|
279
|
+
if (!answer) return { ok: false, error: "The server can't be reached. Nothing was discarded; try again in a moment.", code: null };
|
|
280
|
+
if (answer.status !== 200) {
|
|
281
|
+
const said = answer.body as DiscardRefusal;
|
|
282
|
+
return { ok: false, error: typeof said?.error === 'string' && said.error ? said.error : `The server said no (${answer.status}).`, code: said?.code ?? null };
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, body: answer.body as DiscardResponse };
|
|
285
|
+
},
|
|
286
|
+
async discardSeen(file) {
|
|
287
|
+
await call(ENDPOINTS.discard, { method: 'POST', body: { file, seen: true } });
|
|
288
|
+
},
|
|
272
289
|
active() {
|
|
273
290
|
used();
|
|
274
291
|
},
|
|
@@ -10,7 +10,7 @@ import type { LiveEditor } from '../../live/editor.ts';
|
|
|
10
10
|
import { personOf } from '../../live/locals.ts';
|
|
11
11
|
import { liveEditorOf } from '../server/mode.ts';
|
|
12
12
|
|
|
13
|
-
type Name = 'status' | 'lock' | 'publish' | 'history' | 'tryAgain' | 'notice';
|
|
13
|
+
type Name = 'status' | 'lock' | 'publish' | 'history' | 'tryAgain' | 'notice' | 'discard';
|
|
14
14
|
|
|
15
15
|
export const liveRoute =
|
|
16
16
|
(name: Name): APIRoute =>
|
|
@@ -20,7 +20,9 @@ import geistMono from '../fonts/geist-mono-latin-wght-normal.woff2?url';
|
|
|
20
20
|
import instrumentSans from '../fonts/instrument-sans-latin-wght-normal.woff2?url';
|
|
21
21
|
import { site } from 'virtual:caracms/config';
|
|
22
22
|
import { monogramIcon } from '../brand.ts';
|
|
23
|
+
import tokensCss from '../styles/tokens.css?raw';
|
|
23
24
|
import { adminProps } from '../server/admin-props.ts';
|
|
25
|
+
import { emptySitePage } from '../server/empty-site.ts';
|
|
24
26
|
import { personOf } from '../../live/locals.ts';
|
|
25
27
|
import { editorMode, editorStore } from '../server/mode.ts';
|
|
26
28
|
import { renderEditor } from '../server/render.tsx';
|
|
@@ -39,6 +41,13 @@ if (editorMode().kind === 'live' && !personOf(Astro.locals)) {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const result = await adminProps({ site, store: await editorStore(site), url: Astro.url, cookie: (name) => Astro.cookies.get(name)?.value, person: personOf(Astro.locals) });
|
|
44
|
+
if (!result.ok && result.empty) {
|
|
45
|
+
// Editor wording note (caracms-cloud adversarial review): a new site's /admin says what's missing and what to do.
|
|
46
|
+
return new Response(emptySitePage(result.empty, tokensCss), {
|
|
47
|
+
status: result.status,
|
|
48
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
42
51
|
if (!result.ok) {
|
|
43
52
|
const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
44
53
|
return new Response(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/discard { file }: Discard changes, one waiting document back to what's live;
|
|
3
|
+
* { file, seen: true }: this tab has let go of its copy of a document someone discarded.
|
|
4
|
+
* Live mode only (local mode has no waiting list or published state to go back to); the
|
|
5
|
+
* handler is ../../live/editor.ts → `discard`. Shapes: ../types.ts → DiscardRequest.
|
|
6
|
+
*/
|
|
7
|
+
import { liveRoute } from './_live.ts';
|
|
8
|
+
|
|
9
|
+
export const prerender = false;
|
|
10
|
+
|
|
11
|
+
export const ALL = liveRoute('discard');
|
|
@@ -72,7 +72,16 @@ export function editorSite(site: ResolvedSite, documents: readonly ListedDocumen
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Editor wording note (caracms-cloud adversarial review): `/admin` on a site with no page to
|
|
77
|
+
* open yet (a new site, right after `init`): the file Home would be, site-relative, for the
|
|
78
|
+
* empty state (empty-site.ts) instead of a bare "no document" line.
|
|
79
|
+
*/
|
|
80
|
+
export interface EmptySite {
|
|
81
|
+
file: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type AdminPropsResult = { ok: true; props: AdminProps } | { ok: false; status: number; message: string; empty?: EmptySite };
|
|
76
85
|
|
|
77
86
|
export async function adminProps(input: {
|
|
78
87
|
site: ResolvedSite;
|
|
@@ -106,7 +115,14 @@ export async function adminProps(input: {
|
|
|
106
115
|
const inboxFirst = inbox && submissions.unread !== null ? await inboxFirstPaint(site, url, inbox.id, input.person ?? null) : null;
|
|
107
116
|
return { ok: true, props: { site: editor, documents, opened, ui, unsent, live, media: mediaSetup(), inbox, ...(arrange ? { arrange } : {}), submissions, forms: formsOf(site), inboxFirst } };
|
|
108
117
|
} catch (error) {
|
|
109
|
-
if (error instanceof DocumentError)
|
|
118
|
+
if (error instanceof DocumentError) {
|
|
119
|
+
// No page to open at all (homeOf found none): the empty state, at the editor's own address only.
|
|
120
|
+
const bare = url.pathname.replace(/\/+$/, '') === '/admin';
|
|
121
|
+
const home = editor.home;
|
|
122
|
+
const nothing = error.code === 'not-found' && bare && ref.collection === home.collection && ref.path === home.path && !documents.some((d) => d.kind === 'sections' && !d.warning);
|
|
123
|
+
const empty = nothing ? { file: `${relative(site.root, site.collections[home.collection]!.dir).split('\\').join('/')}/${home.path}.json` } : undefined;
|
|
124
|
+
return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message, ...(empty ? { empty } : {}) };
|
|
125
|
+
}
|
|
110
126
|
throw error;
|
|
111
127
|
}
|
|
112
128
|
}
|
|
@@ -25,6 +25,7 @@ import type { ArrangeRequest, ArrangeResponse } from '../types.ts';
|
|
|
25
25
|
import { refused } from './create.ts';
|
|
26
26
|
import { movesUnder, type PathMove } from './move.ts';
|
|
27
27
|
import { checkOrigin, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
|
|
28
|
+
import { readCappedText } from '../../body.ts';
|
|
28
29
|
|
|
29
30
|
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
30
31
|
|
|
@@ -225,10 +226,9 @@ export async function handleArrange(request: Request, store: Arranges, who: { pe
|
|
|
225
226
|
checkOrigin(request);
|
|
226
227
|
const type = request.headers.get('content-type') ?? '';
|
|
227
228
|
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (Buffer.byteLength(raw) > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
229
|
+
// caracms-cloud adversarial review, finding 10: counted as it's read, so a chunked body stops at the cap too.
|
|
230
|
+
const raw = await readCappedText(request, MAX_ARRANGE_BODY_BYTES);
|
|
231
|
+
if (raw === null) throw new WriteRefused(413, 'That body is too large.');
|
|
232
232
|
let body: unknown;
|
|
233
233
|
try {
|
|
234
234
|
body = JSON.parse(raw);
|
|
@@ -54,6 +54,7 @@ import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLU
|
|
|
54
54
|
import { RELEASE_HEADER, type CreateDeleteCode, type CreateRequest, type CreateResponse, type DeleteRequest, type DeleteResponse } from '../types.ts';
|
|
55
55
|
import { checkOrigin, confineDocumentFile, confinePath, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
|
|
56
56
|
import { schemaRef, stampSchema } from '../../schema-key.ts';
|
|
57
|
+
import { readCappedText } from '../../body.ts';
|
|
57
58
|
|
|
58
59
|
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
|
|
59
60
|
|
|
@@ -395,10 +396,9 @@ async function handle(request: Request, who: { person?: Person | null }, run: (b
|
|
|
395
396
|
checkOrigin(request);
|
|
396
397
|
const type = request.headers.get('content-type') ?? '';
|
|
397
398
|
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (Buffer.byteLength(raw) > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
399
|
+
// caracms-cloud adversarial review, finding 10: counted as it's read, so a chunked body stops at the cap too.
|
|
400
|
+
const raw = await readCappedText(request, MAX_CREATE_BODY_BYTES);
|
|
401
|
+
if (raw === null) throw new WriteRefused(413, 'That body is too large.');
|
|
402
402
|
let body: unknown;
|
|
403
403
|
try {
|
|
404
404
|
body = JSON.parse(raw);
|
|
@@ -26,7 +26,7 @@ export interface DraftsStore {
|
|
|
26
26
|
* each with its public URL and its kind; a shared block with its `usedOn`.
|
|
27
27
|
*/
|
|
28
28
|
list(): Promise<ListedDocument[]>;
|
|
29
|
-
/** One document, of any kind (M5a). Throws content.ts's DocumentError ('not-found', 'bad-path', a warning code…). */
|
|
29
|
+
/** One document, of any kind (M5a). Throws content.ts's DocumentError ('not-found', 'bad-path', a warning code…); one with no title opens (caracms-cloud adversarial review, finding 15). */
|
|
30
30
|
read(collection: string, path: string): Promise<AnyOpenedDocument>;
|
|
31
31
|
/**
|
|
32
32
|
* The store's content as a content.ts source (M5a Decision 9): what a preview's
|
|
@@ -107,7 +107,7 @@ export function localDraftsStore(site: Site): DraftsStore {
|
|
|
107
107
|
);
|
|
108
108
|
},
|
|
109
109
|
async read(collection, path) {
|
|
110
|
-
const read = await readDocument(site, collection, path);
|
|
110
|
+
const read = await readDocument(site, collection, path, diskSource, { untitled: true });
|
|
111
111
|
const { mtimeMs } = await stat(join(site.root, read.entry.file));
|
|
112
112
|
return { ...read, mtimeMs, version: fileVersion(read.text) };
|
|
113
113
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin` on a site with no page yet (caracms-cloud adversarial review, editor wording note):
|
|
3
|
+
* right after `caracms init`, before any page is in `content/`, the editor has nothing to
|
|
4
|
+
* open. Instead of a bare "no document" line whose link went back to `/admin` itself, a page
|
|
5
|
+
* that says what's missing and what to do next, linking to the README's Start section.
|
|
6
|
+
* `init` doesn't write pages (the `caracms-adopt` skill brings them in), so it isn't offered
|
|
7
|
+
* as the way to make one. Styled like the sign-in pages' narrow note (`.nw`, on the
|
|
8
|
+
* editor's tokens), with no script.
|
|
9
|
+
*/
|
|
10
|
+
import { html, LOGIN_CSS } from '../../live/auth/views.ts';
|
|
11
|
+
import type { EmptySite } from './admin-props.ts';
|
|
12
|
+
|
|
13
|
+
/** The package README's getting-started section, on npm. */
|
|
14
|
+
export const START_URL = 'https://www.npmjs.com/package/caracms#start';
|
|
15
|
+
|
|
16
|
+
const EMPTY_CSS = `.nw code{font-family:var(--font-mono);font-size:var(--text-sm);padding:1px 5px;border-radius:var(--radius-sm);background:var(--surface);border:1px solid var(--border);color:var(--text);overflow-wrap:anywhere}
|
|
17
|
+
.nw a{color:var(--select-strong);font-weight:var(--weight-medium)}`;
|
|
18
|
+
|
|
19
|
+
/** The whole page. `tokensCss`: the editor's tokens (styles/tokens.css), as text. */
|
|
20
|
+
export function emptySitePage(empty: EmptySite, tokensCss: string): string {
|
|
21
|
+
const body = html`<main class="nw" data-empty-site=""><div class="nw-in"><h1>This site has no pages yet</h1><p>The editor opens on the home page, and there isn't one. Add <code>${empty.file}</code> (a title and no blocks is enough: <code>{ "title": "Home", "blocks": [] }</code>), then reload this page.</p><p>To bring a page the site already has into the editor, ask your coding agent to use the <code>caracms-adopt</code> skill.</p><p class="fine"><a href="${START_URL}">Getting started with caracms</a></p></div></main>`;
|
|
22
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>No pages yet</title><style>${tokensCss}${LOGIN_CSS}${EMPTY_CSS}</style></head><body>${body}</body></html>`;
|
|
23
|
+
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
|
|
13
13
|
* them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
|
|
14
|
-
* with the cloud's sentence as sent;
|
|
14
|
+
* with the cloud's sentence as sent; `uploads_in_flight` (the site already has two clips
|
|
15
|
+
* uploading) is a 429 with the cloud's sentence as sent, not logged: it's the cloud asking
|
|
16
|
+
* the person to wait, not a failure; any other refusal (`video_unavailable`, the key's own
|
|
15
17
|
* refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
|
|
16
18
|
* an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
|
|
17
19
|
* logged with the refusal's code. The key is never logged nor put in an error.
|
|
@@ -42,6 +44,7 @@ export function cloudMux(
|
|
|
42
44
|
if (!isCloudRefusal(error)) throw error;
|
|
43
45
|
if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
|
|
44
46
|
if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
|
|
47
|
+
if (error.error === 'uploads_in_flight') throw new MuxRouteError(429, error.message);
|
|
45
48
|
log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
46
49
|
throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
|
|
47
50
|
}
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
|
|
17
17
|
* cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
|
|
18
18
|
* beside it is a problem sentence naming them (one credential, not two).
|
|
19
|
+
* - **With the config's `services`** (caracms-cloud M5 Decision 2) the other mode's variables
|
|
20
|
+
* are never read (live/services.ts), and a cloud site without its key has neither.
|
|
19
21
|
*/
|
|
20
22
|
import type { CloudConnection } from '../../../live/cloud.ts';
|
|
21
23
|
import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
|
|
24
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../../../live/services.ts';
|
|
22
25
|
import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
23
26
|
|
|
24
27
|
type Env = Record<string, string | undefined>;
|
|
@@ -53,15 +56,17 @@ const get = (env: Env, name: string) => {
|
|
|
53
56
|
const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
|
|
54
57
|
|
|
55
58
|
/** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
|
|
56
|
-
export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
|
|
59
|
+
export function readImagesConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): ImagesConfig {
|
|
60
|
+
env = servicesEnv(env, services);
|
|
57
61
|
if (memory(env)) {
|
|
58
62
|
if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
59
|
-
const signing = readMediaConfig(env, production);
|
|
63
|
+
const signing = readMediaConfig(env, production, services);
|
|
60
64
|
if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
61
65
|
return { kind: 'memory', signing: signing.settings };
|
|
62
66
|
}
|
|
63
|
-
|
|
64
|
-
const
|
|
67
|
+
if (services === 'cloud' && !get(env, 'CARACMS_CLOUD_KEY')) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
|
|
68
|
+
const signing = readMediaConfig(env, production, services);
|
|
69
|
+
const cloud = cloudMedia(env, services);
|
|
65
70
|
if (cloud) {
|
|
66
71
|
if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
|
|
67
72
|
return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
|
|
@@ -86,9 +91,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
|
|
89
|
-
export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
|
|
94
|
+
export function readMuxConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MuxConfig {
|
|
95
|
+
env = servicesEnv(env, services);
|
|
90
96
|
if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
91
97
|
const key = get(env, 'CARACMS_CLOUD_KEY');
|
|
98
|
+
if (services === 'cloud' && !key) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
|
|
92
99
|
if (key) {
|
|
93
100
|
const problems: string[] = [];
|
|
94
101
|
const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
|
|
@@ -25,47 +25,8 @@ import { MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS, MEDIA_NAME_HEADER, type MediaRefusal
|
|
|
25
25
|
import type { Bucket, ObjectMeta } from './bucket.ts';
|
|
26
26
|
import { countsPixels, sniffUpload } from './sniff.ts';
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
export
|
|
30
|
-
ok: true;
|
|
31
|
-
bytes: Uint8Array;
|
|
32
|
-
}
|
|
33
|
-
export interface OverCap {
|
|
34
|
-
ok: false;
|
|
35
|
-
/** The size, when the request said it (Content-Length); null when the read was stopped without knowing. */
|
|
36
|
-
size: number | null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Read a request's body, stopping as soon as it passes `cap` bytes. */
|
|
40
|
-
export async function readCapped(request: Request, cap: number): Promise<CappedBody | OverCap> {
|
|
41
|
-
const declared = Number(request.headers.get('content-length') ?? NaN);
|
|
42
|
-
const said = Number.isFinite(declared) && declared >= 0 ? declared : null;
|
|
43
|
-
if (said !== null && said > cap) {
|
|
44
|
-
await request.body?.cancel().catch(() => {});
|
|
45
|
-
return { ok: false, size: said };
|
|
46
|
-
}
|
|
47
|
-
const reader = request.body?.getReader();
|
|
48
|
-
if (!reader) return { ok: true, bytes: new Uint8Array(0) };
|
|
49
|
-
const chunks: Uint8Array[] = [];
|
|
50
|
-
let total = 0;
|
|
51
|
-
for (;;) {
|
|
52
|
-
const { done, value } = await reader.read();
|
|
53
|
-
if (done) break;
|
|
54
|
-
total += value.byteLength;
|
|
55
|
-
if (total > cap) {
|
|
56
|
-
await reader.cancel().catch(() => {});
|
|
57
|
-
return { ok: false, size: said !== null && said > total ? said : null };
|
|
58
|
-
}
|
|
59
|
-
chunks.push(value);
|
|
60
|
-
}
|
|
61
|
-
const bytes = new Uint8Array(total);
|
|
62
|
-
let at = 0;
|
|
63
|
-
for (const c of chunks) {
|
|
64
|
-
bytes.set(c, at);
|
|
65
|
-
at += c.byteLength;
|
|
66
|
-
}
|
|
67
|
-
return { ok: true, bytes };
|
|
68
|
-
}
|
|
28
|
+
// The capped read moved to body.ts (caracms-cloud adversarial review, finding 10), for every route that takes a body.
|
|
29
|
+
export { readCapped, type CappedBody, type OverCap } from '../../../body.ts';
|
|
69
30
|
|
|
70
31
|
/** "52", or "40.3" when rounding would land on the cap itself (so it never reads as allowed). */
|
|
71
32
|
function amount(value: number, cap: number): string {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { ResolvedSite } from '../../config.ts';
|
|
16
16
|
import { liveEditor, type LiveEditor } from '../../live/editor.ts';
|
|
17
17
|
import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
|
|
18
|
+
import { bakedServices } from '../../live/services.ts';
|
|
18
19
|
import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
|
|
19
20
|
|
|
20
21
|
declare const __CARACMS_PRODUCTION__: boolean | undefined;
|
|
@@ -26,11 +27,12 @@ export function isProductionServer(): boolean {
|
|
|
26
27
|
return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/** The site's address and the
|
|
30
|
+
/** The site's address, the commit and the config's `services` the build baked in (runs-anywhere Decisions 7 and 9, caracms-cloud M5 Decision 1); empty in plain Node (tests). */
|
|
30
31
|
export function bakedAtBuild(): Baked {
|
|
31
32
|
return {
|
|
32
33
|
site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
|
|
33
34
|
commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
|
|
35
|
+
services: bakedServices(),
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
|