caracms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write route's logic: M3's one security boundary (Decision 5, Watch-outs).
|
|
3
|
+
*
|
|
4
|
+
* The route takes a path and writes to disk, so it's treated as a boundary even
|
|
5
|
+
* though it only exists under `astro dev` (Decision 4):
|
|
6
|
+
*
|
|
7
|
+
* - **POST only, JSON only.** Anything else is 405 or 415. A request that says it
|
|
8
|
+
* comes from another site (`Origin` not this host, or `Sec-Fetch-Site` cross-site
|
|
9
|
+
* or same-site) is 403, so a page open in another tab can't write through a
|
|
10
|
+
* developer's dev server. curl and the editor itself send neither and pass.
|
|
11
|
+
* - **Confined** (`confineDocumentFile`): the target is a document file inside a
|
|
12
|
+
* declared collection's folder, or the request is refused with a 400. Refused: an
|
|
13
|
+
* absolute path, any `..` or `.` segment (so `../`), a backslash or NUL, a target
|
|
14
|
+
* that isn't `.json`, a folder no collection declares, a dot-file, a nested
|
|
15
|
+
* `index.json`, and anything content.ts → resolveDocumentFile refuses. Then the
|
|
16
|
+
* real path: the target's folder, with symlinks resolved, must still be inside the
|
|
17
|
+
* collection's folder (also resolved), so a link inside a content folder can't
|
|
18
|
+
* write outside it; a target that is itself a symlink is refused.
|
|
19
|
+
* - **Existing documents only.** The write route never creates a document, so a
|
|
20
|
+
* target that doesn't exist is 404. Creating and deleting are their own requests
|
|
21
|
+
* (M5a Phase 3, create.ts), confined the same way.
|
|
22
|
+
* - **The body** is `{ file, base, document }` and nothing else is accepted: a
|
|
23
|
+
* malformed body is 400. `document` is normalised by the schema
|
|
24
|
+
* (document.ts → normaliseDocument), then applied to `base` by the serializer
|
|
25
|
+
* (serialize.ts → applyEdits); a serializer refusal (a `source` that isn't in the
|
|
26
|
+
* base, a base that isn't a JSON object) is 400.
|
|
27
|
+
* - **Atomic:** the new text goes to a temp file in the same folder, then is renamed
|
|
28
|
+
* over the document. A text identical to what's on disk isn't written at all.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
31
|
+
import { lstat, realpath, rename, rm, stat, writeFile, readFile } from 'node:fs/promises';
|
|
32
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
33
|
+
import { SETTINGS_PATH, type ResolvedSite } from '../../config.ts';
|
|
34
|
+
import { DocumentError, resolveDocumentFile } from '../../content.ts';
|
|
35
|
+
import { normaliseAnyDocument, type AnyDocument } from '../../document.ts';
|
|
36
|
+
import type { Person } from '../../live/messages.ts';
|
|
37
|
+
import { RELEASE_HEADER, SEQ_HEADER, TAB_HEADER } from '../types.ts';
|
|
38
|
+
import { applyDocumentEdits } from '../../serialize.ts';
|
|
39
|
+
|
|
40
|
+
/** The body the editor POSTs to the write route. */
|
|
41
|
+
export interface WriteRequest {
|
|
42
|
+
/** The document's file, relative to the site's root, `/`-separated: DocumentEntry.file. */
|
|
43
|
+
file: string;
|
|
44
|
+
/**
|
|
45
|
+
* The file's text as the editor first read it: the serializer's base, which the
|
|
46
|
+
* blocks' `source` indexes point into (serialize.ts). It travels with every write,
|
|
47
|
+
* including edits re-sent from the browser's storage after a restart.
|
|
48
|
+
*/
|
|
49
|
+
base: string;
|
|
50
|
+
/** The whole document as the editor has it: any kind, the file's collection's (M5a). */
|
|
51
|
+
document: AnyDocument;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 200: the document is on disk as the editor has it. */
|
|
55
|
+
export interface WriteResponse {
|
|
56
|
+
ok: true;
|
|
57
|
+
file: string;
|
|
58
|
+
/** False when the file already held exactly this text (nothing was written). */
|
|
59
|
+
changed: boolean;
|
|
60
|
+
/** The file's modification time after the write, ms since the epoch. */
|
|
61
|
+
mtimeMs: number;
|
|
62
|
+
/** The file's version after the write (fileVersion of its text). */
|
|
63
|
+
version: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Any refusal: `{ error }`, a plain sentence, with a 4xx or 5xx status. */
|
|
67
|
+
export interface WriteError {
|
|
68
|
+
error: string;
|
|
69
|
+
/** Live mode: `locked` (409), `signed-out` (401), `unavailable` (503), `no-tab` (400). */
|
|
70
|
+
code?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class WriteRefused extends Error {
|
|
74
|
+
readonly status: number;
|
|
75
|
+
/** More of the answer's body beside `error` (live mode's 409 carries the lock's state). */
|
|
76
|
+
readonly body: Record<string, unknown>;
|
|
77
|
+
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = 'WriteRefused';
|
|
80
|
+
this.status = status;
|
|
81
|
+
this.body = body;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The tab id from the header, or null when it's missing or not an id (8–64 of A–Z, a–z, 0–9, `-`, `_`). */
|
|
86
|
+
export function tabOf(request: Request): string | null {
|
|
87
|
+
const tab = request.headers.get(TAB_HEADER)?.trim() ?? '';
|
|
88
|
+
return /^[A-Za-z0-9_-]{8,64}$/.test(tab) ? tab : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Who is writing, from where (M4). Local mode ignores it. */
|
|
92
|
+
export interface WriteContext {
|
|
93
|
+
/** The signed-in person (live mode; null in local mode). */
|
|
94
|
+
person: Person | null;
|
|
95
|
+
/** The tab's id (`X-Caracms-Tab`), or null. */
|
|
96
|
+
tab: string | null;
|
|
97
|
+
/** `X-Caracms-Release: 1`: this is the closing tab's last write. */
|
|
98
|
+
release: boolean;
|
|
99
|
+
/** `X-Caracms-Seq`: the tab's own write sequence, rising with every write (null when absent). */
|
|
100
|
+
seq?: number | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The tab's write sequence from the header: a safe non-negative integer, else null. */
|
|
104
|
+
export function seqOf(request: Request): number | null {
|
|
105
|
+
const raw = request.headers.get(SEQ_HEADER)?.trim() ?? '';
|
|
106
|
+
if (!/^\d{1,16}$/.test(raw)) return null;
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Largest body accepted. A content file is a few KB; this is generous. */
|
|
112
|
+
export const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
113
|
+
|
|
114
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
115
|
+
|
|
116
|
+
export interface ConfinedFile {
|
|
117
|
+
collection: string;
|
|
118
|
+
/** The document's path in its collection (content.ts → DocumentEntry.path). */
|
|
119
|
+
path: string;
|
|
120
|
+
/** The absolute file. */
|
|
121
|
+
absolute: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check a requested file against the site's declared collections and return where
|
|
126
|
+
* it is, or throw WriteRefused (400 for a path that isn't allowed, 404 for a
|
|
127
|
+
* document that doesn't exist). Reads the disk only to resolve symlinks.
|
|
128
|
+
*/
|
|
129
|
+
export async function confineDocumentFile(site: Site, file: unknown): Promise<ConfinedFile> {
|
|
130
|
+
const { collection: name, path, absolute } = confinePath(site, file);
|
|
131
|
+
const collection = site.collections[name]!;
|
|
132
|
+
return { collection: name, path, absolute: await onDisk(collection, path, absolute, file as string) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The path rules alone (no disk): what `confineDocumentFile` checks before it looks at
|
|
137
|
+
* the file. Live mode (M4 Decision 4) uses this, then checks existence against the
|
|
138
|
+
* in-memory `drafts` copy instead of the disk.
|
|
139
|
+
*/
|
|
140
|
+
export function confinePath(site: Site, file: unknown): ConfinedFile {
|
|
141
|
+
const refuse = (message: string) => new WriteRefused(400, message);
|
|
142
|
+
if (typeof file !== 'string' || file.length === 0) throw refuse('"file" must be the document\'s path, relative to the site.');
|
|
143
|
+
if (file.length > 1024) throw refuse('That path is too long.');
|
|
144
|
+
if (file.includes('\0') || file.includes('\\')) throw refuse(`"${printable(file)}" isn't a document path.`);
|
|
145
|
+
if (isAbsolute(file) || /^[a-zA-Z]:/.test(file)) throw refuse(`"${printable(file)}" is an absolute path; send it relative to the site.`);
|
|
146
|
+
const segments = file.split('/');
|
|
147
|
+
if (segments.some((s) => s === '' || s === '.' || s === '..')) throw refuse(`"${printable(file)}" isn't a document path (no "..", "." or empty parts).`);
|
|
148
|
+
if (!file.endsWith('.json')) throw refuse(`"${printable(file)}" isn't a .json document.`);
|
|
149
|
+
|
|
150
|
+
// The collection whose folder holds it (the deepest, if folders sit inside each other).
|
|
151
|
+
const absolute = resolve(site.root, file);
|
|
152
|
+
let match: { name: string; dir: string } | null = null;
|
|
153
|
+
for (const [name, collection] of Object.entries(site.collections)) {
|
|
154
|
+
// Site Settings (M5a Decision 8) is one file, however its folder is shared: only that file is its.
|
|
155
|
+
if (collection.kind === 'settings' && absolute !== resolve(collection.dir, `${SETTINGS_PATH}.json`)) continue;
|
|
156
|
+
const inside = relative(collection.dir, absolute);
|
|
157
|
+
if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) continue;
|
|
158
|
+
if (!match || collection.dir.length > match.dir.length) match = { name, dir: collection.dir };
|
|
159
|
+
}
|
|
160
|
+
if (!match) throw refuse(`"${printable(file)}" isn't in a folder any collection declares.`);
|
|
161
|
+
|
|
162
|
+
const path = relative(match.dir, absolute).split(sep).join('/').slice(0, -'.json'.length);
|
|
163
|
+
let confined: string;
|
|
164
|
+
try {
|
|
165
|
+
confined = resolveDocumentFile(site, match.name, path);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof DocumentError) throw refuse(error.message);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
if (confined !== absolute) throw refuse(`"${printable(file)}" isn't a document path.`);
|
|
171
|
+
const collection = site.collections[match.name]!;
|
|
172
|
+
if (collection.nested && path.endsWith('/index')) {
|
|
173
|
+
throw refuse(`"${printable(file)}": index.json only means the top of ${collection.label}, so it can't be written.`);
|
|
174
|
+
}
|
|
175
|
+
return { collection: match.name, path, absolute };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The disk half: the file exists, is a plain file, and no link leads it outside its collection's folder. */
|
|
179
|
+
async function onDisk(collection: Site['collections'][string], path: string, absolute: string, file: string): Promise<string> {
|
|
180
|
+
const refuse = (message: string) => new WriteRefused(400, message);
|
|
181
|
+
// Symlinks: the folder the file is in, resolved, must still be inside the collection's folder, resolved.
|
|
182
|
+
let realDir: string;
|
|
183
|
+
let realParent: string;
|
|
184
|
+
try {
|
|
185
|
+
realDir = await realpath(collection.dir);
|
|
186
|
+
realParent = await realpath(dirname(absolute));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new WriteRefused(404, `${collection.label}: no document "${path}".`);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
if (realParent !== realDir && !realParent.startsWith(realDir + sep)) {
|
|
192
|
+
throw refuse(`"${printable(file)}" leads outside ${collection.label}'s folder through a link.`);
|
|
193
|
+
}
|
|
194
|
+
const target = join(realParent, basename(absolute));
|
|
195
|
+
let info;
|
|
196
|
+
try {
|
|
197
|
+
info = await lstat(target);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new WriteRefused(404, `${collection.label}: no document "${path}".`);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
if (info.isSymbolicLink()) throw refuse(`"${printable(file)}" is a link; the editor only writes plain files.`);
|
|
203
|
+
if (!info.isFile()) throw refuse(`"${printable(file)}" isn't a file.`);
|
|
204
|
+
return target;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Validate the parsed body's shape. Throws WriteRefused 400 with what's wrong. */
|
|
208
|
+
export function parseWriteBody(body: unknown): WriteRequest {
|
|
209
|
+
const refuse = (message: string) => new WriteRefused(400, `Malformed body: ${message} Send { file, base, document }.`);
|
|
210
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw refuse('it must be a JSON object.');
|
|
211
|
+
const { file, base, document, ...rest } = body as Record<string, unknown>;
|
|
212
|
+
const extra = Object.keys(rest);
|
|
213
|
+
if (extra.length > 0) throw refuse(`unexpected ${extra.map((k) => `"${printable(k)}"`).join(', ')}.`);
|
|
214
|
+
if (typeof file !== 'string') throw refuse('"file" must be a string.');
|
|
215
|
+
if (typeof base !== 'string') throw refuse('"base" must be the file\'s text as first read.');
|
|
216
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) throw refuse('"document" must be an object.');
|
|
217
|
+
const doc = document as Record<string, unknown>;
|
|
218
|
+
if (typeof doc.title !== 'string') throw refuse('"document.title" must be a string.');
|
|
219
|
+
const isObject = (v: unknown) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
220
|
+
const checkBlock = (b: unknown, i: number | null) => {
|
|
221
|
+
const which = i === null ? '"document.block"' : `block ${i}`;
|
|
222
|
+
if (!isObject(b)) throw refuse(`${which} must be an object.`);
|
|
223
|
+
const block = b as Record<string, unknown>;
|
|
224
|
+
if (i !== null && !(block.source === null || (Number.isSafeInteger(block.source) && (block.source as number) >= 0))) {
|
|
225
|
+
throw refuse(`${which}: "source" must be its index in the file as read, or null.`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof block.type !== 'string') throw refuse(`${which}: "type" must be a string.`);
|
|
228
|
+
if (!isObject(block.data)) throw refuse(`${which}: "data" must be an object.`);
|
|
229
|
+
};
|
|
230
|
+
switch (doc.kind) {
|
|
231
|
+
case undefined:
|
|
232
|
+
case 'sections':
|
|
233
|
+
if (!Array.isArray(doc.blocks)) throw refuse('"document.blocks" must be an array.');
|
|
234
|
+
doc.blocks.forEach((b, i) => checkBlock(b, i));
|
|
235
|
+
break;
|
|
236
|
+
case 'fields':
|
|
237
|
+
case 'settings':
|
|
238
|
+
if (!isObject(doc.data)) throw refuse('"document.data" must be an object.');
|
|
239
|
+
break;
|
|
240
|
+
case 'shared':
|
|
241
|
+
checkBlock(doc.block, null);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
throw refuse('"document.kind" must be "fields", "shared", "settings", or absent for a section builder.');
|
|
245
|
+
}
|
|
246
|
+
return { file, base, document: document as AnyDocument };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The text a write leaves: the request's document, normalised by its collection's
|
|
251
|
+
* schema, applied to its base (M5a: any kind). Throws WriteRefused 400 when it
|
|
252
|
+
* doesn't apply, or isn't the collection's kind.
|
|
253
|
+
*/
|
|
254
|
+
export function writeText(site: Site, collection: string, request: WriteRequest): string {
|
|
255
|
+
const c = site.collections[collection]!;
|
|
256
|
+
if ((request.document.kind ?? 'sections') !== c.kind) {
|
|
257
|
+
throw new WriteRefused(400, `That's a ${request.document.kind ?? 'sections'} document, but ${c.label} holds ${c.kind} documents.`);
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
return applyDocumentEdits(request.base, normaliseAnyDocument(request.document, site, c), site, c);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
throw new WriteRefused(400, `The edits don't apply to the base sent with them (${message}).`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Refuse a request that comes from another site. The editor's own requests are
|
|
269
|
+
* same-origin; curl sends neither header.
|
|
270
|
+
*/
|
|
271
|
+
export function checkOrigin(request: Request): void {
|
|
272
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
273
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
|
|
274
|
+
throw new WriteRefused(403, 'Writes are only accepted from the editor on this site.');
|
|
275
|
+
}
|
|
276
|
+
const origin = request.headers.get('origin');
|
|
277
|
+
if (origin === null) return;
|
|
278
|
+
let host: string | null = null;
|
|
279
|
+
try {
|
|
280
|
+
host = new URL(origin).host; // "null" (a sandboxed frame, a file) doesn't parse: refused
|
|
281
|
+
} catch {
|
|
282
|
+
/* refused below */
|
|
283
|
+
}
|
|
284
|
+
if (!host || host !== new URL(request.url).host) {
|
|
285
|
+
throw new WriteRefused(403, 'Writes are only accepted from the editor on this site.');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A file's version: a short hash of its text. The editor compares versions (equal or
|
|
291
|
+
* not), never times, to tell whether a file changed since it last saw it (keeper.ts).
|
|
292
|
+
*/
|
|
293
|
+
export function fileVersion(text: string): string {
|
|
294
|
+
return createHash('sha256').update(text).digest('base64url').slice(0, 16);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Write `text` to `file` atomically: a temp file in the same folder, then a rename over it. */
|
|
298
|
+
export async function writeAtomic(file: string, text: string): Promise<void> {
|
|
299
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
300
|
+
const mode = (await stat(file).catch(() => null))?.mode;
|
|
301
|
+
try {
|
|
302
|
+
await writeFile(temp, text, { encoding: 'utf8', flag: 'wx', mode: mode === undefined ? 0o644 : mode & 0o777 });
|
|
303
|
+
await rename(temp, file);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
await rm(temp, { force: true });
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Apply a validated request: confine, serialise, write. Returns the response body. */
|
|
311
|
+
export async function applyWrite(site: Site, request: WriteRequest): Promise<WriteResponse> {
|
|
312
|
+
const target = await confineDocumentFile(site, request.file);
|
|
313
|
+
const text = writeText(site, target.collection, request);
|
|
314
|
+
const current = await readFile(target.absolute, 'utf8');
|
|
315
|
+
const changed = current !== text;
|
|
316
|
+
if (changed) await writeAtomic(target.absolute, text);
|
|
317
|
+
const { mtimeMs } = await stat(target.absolute);
|
|
318
|
+
return { ok: true, file: request.file, changed, mtimeMs, version: fileVersion(text) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const json = (status: number, body: WriteResponse | WriteError, headers: Record<string, string> = {}) =>
|
|
322
|
+
new Response(JSON.stringify(body), {
|
|
323
|
+
status,
|
|
324
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The whole route: a Request in, a Response out. `store` is the drafts store
|
|
329
|
+
* (drafts-store.ts), whose local `write` is applyWrite above.
|
|
330
|
+
*/
|
|
331
|
+
export async function handleWrite(
|
|
332
|
+
request: Request,
|
|
333
|
+
store: { write(request: WriteRequest, context?: WriteContext): Promise<WriteResponse> },
|
|
334
|
+
who: { person?: Person | null } = {},
|
|
335
|
+
): Promise<Response> {
|
|
336
|
+
try {
|
|
337
|
+
if (request.method !== 'POST') throw new WriteRefused(405, 'The write route only takes POST.');
|
|
338
|
+
checkOrigin(request);
|
|
339
|
+
const type = request.headers.get('content-type') ?? '';
|
|
340
|
+
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
341
|
+
const length = Number(request.headers.get('content-length') ?? 0);
|
|
342
|
+
if (length > MAX_BODY_BYTES) throw new WriteRefused(413, 'That body is too large for a document.');
|
|
343
|
+
const raw = await request.text();
|
|
344
|
+
if (Buffer.byteLength(raw) > MAX_BODY_BYTES) throw new WriteRefused(413, 'That body is too large for a document.');
|
|
345
|
+
let body: unknown;
|
|
346
|
+
try {
|
|
347
|
+
body = JSON.parse(raw);
|
|
348
|
+
} catch {
|
|
349
|
+
throw new WriteRefused(400, 'Malformed body: it isn\'t valid JSON. Send { file, base, document }.');
|
|
350
|
+
}
|
|
351
|
+
const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: request.headers.get(RELEASE_HEADER) === '1', seq: seqOf(request) };
|
|
352
|
+
return json(200, await store.write(parseWriteBody(body), context));
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error instanceof WriteRefused) {
|
|
355
|
+
return json(error.status, { error: error.message, ...error.body } as WriteError, error.status === 405 ? { Allow: 'POST' } : {});
|
|
356
|
+
}
|
|
357
|
+
console.error('[caracms] write failed:', error);
|
|
358
|
+
return json(500, { error: `The document couldn't be written (${error instanceof Error ? error.message : String(error)}).` });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** A path for an error message: control characters shown escaped, long ones cut. */
|
|
363
|
+
function printable(value: string): string {
|
|
364
|
+
const shown = JSON.stringify(value).slice(1, -1);
|
|
365
|
+
return shown.length > 120 ? `${shown.slice(0, 117)}...` : shown;
|
|
366
|
+
}
|