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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /_caracms/media/upload` (M5b Decision 6): one image, or (M6c G4) one file, through the site's server.
|
|
3
|
+
* A security boundary: it takes bytes and a name from the browser and writes to the
|
|
4
|
+
* bucket that every place the site runs shares, append-only.
|
|
5
|
+
*
|
|
6
|
+
* 1. **The body is buffered in memory up to 40 MB and refused the moment it passes**:
|
|
7
|
+
* a Content-Length over the cap is refused before a byte is read, and the read itself
|
|
8
|
+
* stops at the cap whatever the header said (it's not trusted). Not streamed to the
|
|
9
|
+
* bucket: the key comes from the file's hash, known only at the last byte.
|
|
10
|
+
* 2. **The type is the bytes'** (sniff.ts), never the name's or the Content-Type's
|
|
11
|
+
* (save Decision 1's two narrow exceptions, a legacy Office file and a CSV, sniff.ts).
|
|
12
|
+
* 3. **At most 50 megapixels** (imgproxy's limit), counted as shown; an SVG has no pixels.
|
|
13
|
+
* 4. **The key is `uploads/<first 12 hex of SHA-256>/<the name, slugified>.<ext>`**, with
|
|
14
|
+
* `<ext>` from the detected type (a HEIC called `photo.jpg` is `photo.heic`).
|
|
15
|
+
* 5. **If anything exists under `uploads/<hash>/`, nothing is written** and that object is
|
|
16
|
+
* the answer, whatever it was called: the same bytes are stored once, and a key never
|
|
17
|
+
* changes what it points at (the CDN keeps every copy for a year). Two uploads of the
|
|
18
|
+
* same bytes in this process at once are one.
|
|
19
|
+
* 6. The metadata: `w`, `h` (an image's; a file has neither), `name` (the file name as
|
|
20
|
+
* sent) and `by` (the signed-in address; none in local mode).
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { slugify } from '../../../slug.ts';
|
|
24
|
+
import { MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS, MEDIA_NAME_HEADER, type MediaRefusal } from '../../types.ts';
|
|
25
|
+
import type { Bucket, ObjectMeta } from './bucket.ts';
|
|
26
|
+
import { countsPixels, sniffUpload } from './sniff.ts';
|
|
27
|
+
|
|
28
|
+
/** How many bytes are read before deciding (the cap, plus one to know it was passed). */
|
|
29
|
+
export interface CappedBody {
|
|
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
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "52", or "40.3" when rounding would land on the cap itself (so it never reads as allowed). */
|
|
71
|
+
function amount(value: number, cap: number): string {
|
|
72
|
+
const whole = Math.round(value);
|
|
73
|
+
return whole > cap ? String(whole) : (Math.ceil(value * 10 - 1e-9) / 10).toFixed(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const tooLargeMessage = (bytes: number | null) =>
|
|
77
|
+
bytes === null
|
|
78
|
+
? `This file is over ${MEDIA_MAX_BYTES / 1e6} MB. The library takes up to ${MEDIA_MAX_BYTES / 1e6} MB.`
|
|
79
|
+
: `This file is ${amount(bytes / 1e6, MEDIA_MAX_BYTES / 1e6)} MB. The library takes up to ${MEDIA_MAX_BYTES / 1e6} MB.`;
|
|
80
|
+
export const tooManyPixelsMessage = (pixels: number) => `This image is ${amount(pixels / 1e6, MEDIA_MAX_PIXELS / 1e6)} megapixels. The library takes up to ${MEDIA_MAX_PIXELS / 1e6}.`;
|
|
81
|
+
|
|
82
|
+
/** An upload refused: the status and Decision 6's sentence. */
|
|
83
|
+
export class UploadRefused extends Error {
|
|
84
|
+
readonly status: number;
|
|
85
|
+
readonly code: MediaRefusal['code'];
|
|
86
|
+
constructor(status: number, code: MediaRefusal['code'], message: string) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = 'UploadRefused';
|
|
89
|
+
this.status = status;
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The most a name may take URI-encoded: S3 holds all of an object's metadata to 2 KB, and `name` is stored encoded. */
|
|
95
|
+
const NAME_ENCODED_MAX = 1024;
|
|
96
|
+
|
|
97
|
+
/** The file name from the header: URI-decoded, no path, no control characters, at most 255 characters and 1 KB encoded. */
|
|
98
|
+
export function uploadName(request: Request): string {
|
|
99
|
+
const raw = request.headers.get(MEDIA_NAME_HEADER) ?? '';
|
|
100
|
+
let name = raw;
|
|
101
|
+
try {
|
|
102
|
+
name = decodeURIComponent(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
/* as sent */
|
|
105
|
+
}
|
|
106
|
+
name = (name.split(/[\\/]/).pop() ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
107
|
+
const chars = [...name].slice(0, 255);
|
|
108
|
+
while (chars.length > 0 && encodeURIComponent(chars.join('')).length > NAME_ENCODED_MAX) chars.pop();
|
|
109
|
+
return chars.join('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The key's file part: the name without its extension, slugified (`image`, or a file's `file`, when nothing's left). */
|
|
113
|
+
export function keyName(name: string, fallback = 'image'): string {
|
|
114
|
+
const dot = name.lastIndexOf('.');
|
|
115
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
116
|
+
return slugify(stem) || fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface Stored {
|
|
120
|
+
key: string;
|
|
121
|
+
meta: ObjectMeta;
|
|
122
|
+
created: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const inFlight = new Map<string, Promise<Stored>>();
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Take an upload's bytes into the bucket by Decision 6's rules. Throws UploadRefused for
|
|
129
|
+
* anything the library doesn't take; a bucket failure throws its BucketError (a 502).
|
|
130
|
+
*/
|
|
131
|
+
export async function storeUpload(
|
|
132
|
+
bucket: Bucket,
|
|
133
|
+
upload: { bytes: Uint8Array; name: string; by: string | null },
|
|
134
|
+
options: { now?: () => number; head?: (key: string) => Promise<ObjectMeta | null> } = {},
|
|
135
|
+
): Promise<Stored> {
|
|
136
|
+
const { bytes, name, by } = upload;
|
|
137
|
+
if (bytes.byteLength === 0) throw new UploadRefused(400, 'empty', 'That file is empty.');
|
|
138
|
+
if (bytes.byteLength > MEDIA_MAX_BYTES) throw new UploadRefused(413, 'too-large', tooLargeMessage(bytes.byteLength));
|
|
139
|
+
const sniffed = sniffUpload(bytes, name);
|
|
140
|
+
if (!sniffed.ok) throw new UploadRefused(415, sniffed.code, sniffed.message);
|
|
141
|
+
const size = sniffed.format === 'file' ? null : { w: sniffed.w, h: sniffed.h };
|
|
142
|
+
if (size && sniffed.format !== 'file' && countsPixels(sniffed.format) && size.w * size.h > MEDIA_MAX_PIXELS) throw new UploadRefused(422, 'too-many-pixels', tooManyPixelsMessage(size.w * size.h));
|
|
143
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
144
|
+
const hash = sha256.slice(0, 12);
|
|
145
|
+
const head = options.head ?? ((key: string) => bucket.head(key));
|
|
146
|
+
const now = options.now ?? (() => Date.now());
|
|
147
|
+
|
|
148
|
+
const run = async (): Promise<Stored> => {
|
|
149
|
+
// Existence first, always: anything under the hash's folder is these bytes already.
|
|
150
|
+
// It's the answer even when its metadata can't be read: writing anyway could land on its key.
|
|
151
|
+
const existing = await bucket.first(`uploads/${hash}/`);
|
|
152
|
+
if (existing) {
|
|
153
|
+
const meta = (await head(existing)) ?? { w: size?.w ?? null, h: size?.h ?? null, name: null, by: null, contentType: sniffed.mime, lastModified: new Date(now()).toISOString(), size: bytes.byteLength };
|
|
154
|
+
return { key: existing, meta, created: false };
|
|
155
|
+
}
|
|
156
|
+
const stem = keyName(name, size ? 'image' : 'file');
|
|
157
|
+
const key = `uploads/${hash}/${stem}.${sniffed.ext}`;
|
|
158
|
+
const named = { name: name || `${stem}.${sniffed.ext}`, by };
|
|
159
|
+
await bucket.put({ key, body: bytes, contentType: sniffed.mime, sha256, meta: size ? { ...size, ...named } : named });
|
|
160
|
+
return {
|
|
161
|
+
key,
|
|
162
|
+
meta: { w: size?.w ?? null, h: size?.h ?? null, ...named, contentType: sniffed.mime, lastModified: new Date(now()).toISOString(), size: bytes.byteLength },
|
|
163
|
+
created: true,
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
let pending = inFlight.get(hash);
|
|
167
|
+
if (!pending) {
|
|
168
|
+
pending = run().finally(() => inFlight.delete(hash));
|
|
169
|
+
inFlight.set(hash, pending);
|
|
170
|
+
return pending;
|
|
171
|
+
}
|
|
172
|
+
// The same bytes, already on their way in this process: one object, so this one wrote nothing.
|
|
173
|
+
const first = await pending;
|
|
174
|
+
return { ...first, created: false };
|
|
175
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's mode at run time (M4 Decision 1), and the drafts store it implies.
|
|
3
|
+
*
|
|
4
|
+
* - `editorMode()`: `readMode(process.env, isProductionServer())`, read once per
|
|
5
|
+
* process. **A production server is the build**, not `NODE_ENV`: the integration
|
|
6
|
+
* sets `__CARACMS_PRODUCTION__` (a Vite define) to `command !== 'dev'`, so the
|
|
7
|
+
* built server is always live mode (or unconfigured), and `astro dev` is local
|
|
8
|
+
* unless the App or `CARACMS_GITHUB=memory` is set. Phase 3's guard reads the
|
|
9
|
+
* mode here too, so the two never disagree.
|
|
10
|
+
* - Unconfigured: the missing variables are logged once, on the first read (the
|
|
11
|
+
* 503 itself is ../middleware.ts's).
|
|
12
|
+
* - `editorStore(site)`: local mode's disk store (M3, unchanged), or live mode's
|
|
13
|
+
* (../../live/drafts-store.ts). `liveEditorOf(site)`: live mode's handlers, or null.
|
|
14
|
+
*/
|
|
15
|
+
import type { ResolvedSite } from '../../config.ts';
|
|
16
|
+
import { liveEditor, type LiveEditor } from '../../live/editor.ts';
|
|
17
|
+
import { readMode, unconfiguredMessage, type Mode } from '../../live/mode.ts';
|
|
18
|
+
import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
|
|
19
|
+
|
|
20
|
+
declare const __CARACMS_PRODUCTION__: boolean | undefined;
|
|
21
|
+
|
|
22
|
+
/** True in the built server (`astro build`), false under `astro dev` and in plain Node (tests). */
|
|
23
|
+
export function isProductionServer(): boolean {
|
|
24
|
+
return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SHARED = Symbol.for('caracms.editorMode');
|
|
28
|
+
type Shared = { [SHARED]?: Mode };
|
|
29
|
+
|
|
30
|
+
/** The mode, read once per process. Sync, no arguments. */
|
|
31
|
+
export function editorMode(): Mode {
|
|
32
|
+
const g = globalThis as Shared;
|
|
33
|
+
if (!g[SHARED]) {
|
|
34
|
+
const mode = readMode(process.env, isProductionServer());
|
|
35
|
+
if (mode.kind === 'unconfigured') console.error(`[caracms] ${unconfiguredMessage(mode)} The public site is served; /admin answers 503.`);
|
|
36
|
+
g[SHARED] = mode;
|
|
37
|
+
}
|
|
38
|
+
return g[SHARED];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Forget the mode read (tests that change the environment). */
|
|
42
|
+
export function resetEditorMode(): void {
|
|
43
|
+
delete (globalThis as Shared)[SHARED];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Live mode's editor server, or null in local mode. Unconfigured never gets here (the middleware's 503). */
|
|
47
|
+
export async function liveEditorOf(site: ResolvedSite): Promise<LiveEditor | null> {
|
|
48
|
+
const mode = editorMode();
|
|
49
|
+
if (mode.kind === 'local') return null;
|
|
50
|
+
if (mode.kind === 'unconfigured') throw new Error(`caracms: ${unconfiguredMessage(mode)}`);
|
|
51
|
+
return liveEditor(site, mode.settings);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const local = new WeakMap<object, DraftsStore>();
|
|
55
|
+
|
|
56
|
+
/** The drafts store for the mode: disk (local) or Redis over the `drafts` copy (live). */
|
|
57
|
+
export async function editorStore(site: ResolvedSite): Promise<DraftsStore> {
|
|
58
|
+
const live = await liveEditorOf(site);
|
|
59
|
+
if (live) return live.store;
|
|
60
|
+
let store = local.get(site);
|
|
61
|
+
if (!store) local.set(site, (store = localDraftsStore(site)));
|
|
62
|
+
return store;
|
|
63
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing a document's address (pre-M7 item 8): the rules both stores apply, what
|
|
3
|
+
* the move changes in every file, the disk half (local mode), and the route.
|
|
4
|
+
*
|
|
5
|
+
* **A security boundary, as create and delete are** (create.ts): the paths come from
|
|
6
|
+
* the browser, so the route is the same POST-JSON-same-origin handler, both paths go
|
|
7
|
+
* through create.ts → `target`, and on disk the new file is made by `createOnDisk` and
|
|
8
|
+
* the old one removed by `deleteOnDisk`, with their symlink checks.
|
|
9
|
+
*
|
|
10
|
+
* The rules (`planMove`), over the store's own listing:
|
|
11
|
+
*
|
|
12
|
+
* - **Only documents with an address move**: a page, a case study, a journal entry.
|
|
13
|
+
* Shared blocks and Site Settings have none. The top of a nested collection (Home,
|
|
14
|
+
* or a collection's own page at its `urlBase`) is its collection's address, so it
|
|
15
|
+
* stays.
|
|
16
|
+
* - **The new address follows a new document's rules** (create.ts): its parts are
|
|
17
|
+
* slugs (a folder that already holds documents keeps its name), none is `index`, and
|
|
18
|
+
* it's free in every collection (the document's own address aside), not under
|
|
19
|
+
* `/admin` or `/_caracms`, not strictly below another collection's `urlBase`. The
|
|
20
|
+
* same address is refused (`same`): there's nothing to change.
|
|
21
|
+
* - **Pages under it stay where they are** (as on a delete, M5a Decision 12): moving
|
|
22
|
+
* Services moves Services' own page, and its pages keep their addresses, as a group.
|
|
23
|
+
*
|
|
24
|
+
* What a move changes (`moveChanges`), worked out from the store's own texts:
|
|
25
|
+
*
|
|
26
|
+
* - the document, at its new path, with its own links to itself rewritten;
|
|
27
|
+
* - **every other document that links to it or references it** (relink.ts), Site
|
|
28
|
+
* Settings included, each changed only where it points at the old address;
|
|
29
|
+
* - **Site Settings' redirects** (redirects.ts): entries to the old address go to the
|
|
30
|
+
* new one (chains collapse), an entry from the new address goes (a page is there
|
|
31
|
+
* now), and `old → new` is added, unless the document was never live (`addRedirect`
|
|
32
|
+
* false: live mode's document made since the last Publish).
|
|
33
|
+
*
|
|
34
|
+
* On disk the order is: the new file (an exclusive create), the rewritten files, Site
|
|
35
|
+
* Settings, then the old file goes. A failure part way leaves the old document where
|
|
36
|
+
* it was, never no document.
|
|
37
|
+
*/
|
|
38
|
+
import { writeFile } from 'node:fs/promises';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { SITE_SETTINGS_FILE, type ResolvedCollection, type ResolvedSite } from '../../config.ts';
|
|
41
|
+
import { documentUrl, type DocumentEntry } from '../../content.ts';
|
|
42
|
+
import { own, parseAnyDocument, type SettingsDocument } from '../../document.ts';
|
|
43
|
+
import type { Person } from '../../live/messages.ts';
|
|
44
|
+
import { redirectsAfterMove, type Redirect } from '../../redirects.ts';
|
|
45
|
+
import { relinkText, type AddressMove } from '../../relink.ts';
|
|
46
|
+
import { schemaRef, stampSchema } from '../../schema-key.ts';
|
|
47
|
+
import { applyKeyedEdits } from '../../serialize.ts';
|
|
48
|
+
import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, slugify } from '../../slug.ts';
|
|
49
|
+
import { RELEASE_HEADER, type MoveRequest, type MoveResponse } from '../types.ts';
|
|
50
|
+
import { confineOnDisk, createOnDisk, deleteOnDisk, MAX_CREATE_BODY_BYTES, refused, target, type Target } from './create.ts';
|
|
51
|
+
import { checkOrigin, seqOf, tabOf, WriteRefused, writeAtomic, type WriteContext } from './write.ts';
|
|
52
|
+
|
|
53
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
|
|
54
|
+
|
|
55
|
+
const MOVE_SHAPE = '{ collection, path, to }';
|
|
56
|
+
|
|
57
|
+
/** A path for a message: control characters shown escaped, long ones cut. */
|
|
58
|
+
const printable = (value: string) => {
|
|
59
|
+
const shown = JSON.stringify(value).slice(1, -1);
|
|
60
|
+
return shown.length > 120 ? `${shown.slice(0, 117)}...` : shown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Validate a move body's shape. Throws WriteRefused 400. */
|
|
64
|
+
export function parseMoveBody(body: unknown): MoveRequest {
|
|
65
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw refused(400, 'bad-request', `Malformed body: it must be a JSON object. Send ${MOVE_SHAPE}.`);
|
|
66
|
+
const b = body as Record<string, unknown>;
|
|
67
|
+
const extra = Object.keys(b).filter((k) => !['collection', 'path', 'to'].includes(k));
|
|
68
|
+
if (extra.length) throw refused(400, 'bad-request', `Malformed body: unexpected ${extra.map((k) => `"${printable(k)}"`).join(', ')}. Send ${MOVE_SHAPE}.`);
|
|
69
|
+
if (typeof b.collection !== 'string' || b.collection === '') throw refused(400, 'bad-request', `Malformed body: "collection" must be a collection's name. Send ${MOVE_SHAPE}.`);
|
|
70
|
+
if (typeof b.path !== 'string' || b.path === '') throw refused(400, 'bad-request', `Malformed body: "path" must be the document's path in its collection. Send ${MOVE_SHAPE}.`);
|
|
71
|
+
if (typeof b.to !== 'string') throw refused(400, 'bad-request', `Malformed body: "to" must be the document's new path in its collection. Send ${MOVE_SHAPE}.`);
|
|
72
|
+
if (b.path.length > 512 || b.to.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
73
|
+
return { collection: b.collection, path: b.path, to: b.to };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MovePlan {
|
|
77
|
+
from: Target & { entry: DocumentEntry; url: string };
|
|
78
|
+
to: Target & { url: string };
|
|
79
|
+
move: AddressMove;
|
|
80
|
+
title: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Check a move against the rules and the store's listing (every document, every collection). Throws WriteRefused. */
|
|
84
|
+
export function planMove(site: Site, request: MoveRequest, documents: readonly DocumentEntry[]): MovePlan {
|
|
85
|
+
const collection = own(site.collections, request.collection);
|
|
86
|
+
if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
|
|
87
|
+
if (collection.urlBase === null) {
|
|
88
|
+
const what = collection.kind === 'shared' ? 'A shared block' : collection.kind === 'settings' ? 'Site Settings' : `${collection.label} has no pages of its own, so a document there`;
|
|
89
|
+
throw refused(400, 'not-allowed', `${what} has no address to change.`);
|
|
90
|
+
}
|
|
91
|
+
const from = target(site, collection.name, request.path);
|
|
92
|
+
const entry = documents.find((d) => d.file === from.file);
|
|
93
|
+
if (!entry) throw refused(404, 'not-found', `${collection.label}: no document "${printable(from.path)}".`);
|
|
94
|
+
const fromUrl = documentUrl(collection, from.path);
|
|
95
|
+
if (collection.nested && from.path === 'index') {
|
|
96
|
+
throw refused(400, 'not-allowed', collection.urlBase === '/' ? 'Home is always at /, so its address can\'t change.' : `${entry.label} is the top of ${collection.label}, at ${collection.urlBase}, so its address can't change.`);
|
|
97
|
+
}
|
|
98
|
+
if (fromUrl === null || entry.warning) throw refused(400, 'not-allowed', `${entry.label} can't be opened, so its address can't change.`);
|
|
99
|
+
|
|
100
|
+
if (request.to === '') throw refused(400, 'bad-name', NO_ADDRESS_MESSAGE);
|
|
101
|
+
if (request.to.split('/').includes(RESERVED_SLUG)) throw refused(400, 'index', INDEX_MESSAGE);
|
|
102
|
+
if (request.to === from.path) throw refused(400, 'same', `${fromUrl} is its address already.`);
|
|
103
|
+
const to = target(site, collection.name, request.to);
|
|
104
|
+
const parts = to.path.split('/');
|
|
105
|
+
parts.forEach((part, i) => {
|
|
106
|
+
// As on a create: a folder that already has documents in it keeps its name, whatever it is.
|
|
107
|
+
const prefix = parts.slice(0, i + 1).join('/');
|
|
108
|
+
const existing = i < parts.length - 1 && documents.some((d) => d.collection === collection.name && d.path.startsWith(`${prefix}/`));
|
|
109
|
+
if (existing || isSlug(part)) return;
|
|
110
|
+
const better = slugify(part);
|
|
111
|
+
throw refused(400, 'bad-name', better ? `"${printable(part)}" can't be part of an address; "${better}" can.` : NO_ADDRESS_MESSAGE);
|
|
112
|
+
});
|
|
113
|
+
const toUrl = documentUrl(collection, to.path)!;
|
|
114
|
+
if (documents.some((d) => d.file === to.file)) throw refused(409, 'address-taken', `There's already a page at ${toUrl}.`);
|
|
115
|
+
const others = documents.filter((d) => d.file !== from.file);
|
|
116
|
+
const collections = Object.values(site.collections).map(({ name, label, urlBase }) => ({ name, label, urlBase }));
|
|
117
|
+
const problem = addressProblem(toUrl, collection.name, collections, others, site.codeRoutes);
|
|
118
|
+
if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, problem.message);
|
|
119
|
+
return {
|
|
120
|
+
from: { ...from, entry, url: fromUrl },
|
|
121
|
+
to: { ...to, url: toUrl },
|
|
122
|
+
move: { collection: collection.name, fromPath: from.path, toPath: to.path, fromUrl, toUrl },
|
|
123
|
+
title: entry.label,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface MoveChanges {
|
|
128
|
+
/** The document's text at its new path. */
|
|
129
|
+
moved: string;
|
|
130
|
+
/** Every other document whose text changes (links or references to it), Site Settings aside. */
|
|
131
|
+
relinked: { file: string; collection: string; path: string; title: string; text: string }[];
|
|
132
|
+
/** Site Settings' text after (null: no change), and before (null: the file isn't there yet). */
|
|
133
|
+
settings: { before: string | null; after: string } | null;
|
|
134
|
+
redirect: Redirect | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const settingsCollection = (site: Site): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Site Settings' text once its redirects follow the move (and its own links are
|
|
141
|
+
* rewritten), or null when nothing changes. `text` null: there's no Site Settings file yet.
|
|
142
|
+
*/
|
|
143
|
+
export function settingsAfterMove(site: Site, text: string | null, move: AddressMove, addRedirect: boolean): string | null {
|
|
144
|
+
const collection = settingsCollection(site);
|
|
145
|
+
if (!collection) return null;
|
|
146
|
+
const relinked = text === null ? null : relinkText(text, move, site, collection).text;
|
|
147
|
+
let raw: unknown = {};
|
|
148
|
+
if (relinked !== null) {
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(relinked);
|
|
151
|
+
} catch {
|
|
152
|
+
return null; // a Site Settings that isn't JSON is left as it is (the editor warns about it)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const doc = parseAnyDocument(raw, site, collection) as SettingsDocument;
|
|
156
|
+
const redirects = redirectsAfterMove(doc.data.redirects ?? [], move.fromUrl, move.toUrl, addRedirect);
|
|
157
|
+
const title = relinked === null ? 'Site Settings' : doc.title;
|
|
158
|
+
const after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
|
|
159
|
+
return after === text ? null : after;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Site Settings' text without its redirect from `url`, or null when it has none: a new
|
|
164
|
+
* document made at an address that redirected (a page is there again). Both stores'
|
|
165
|
+
* create call it; the server also lets a real page win over a redirect.
|
|
166
|
+
*/
|
|
167
|
+
export function settingsWithoutRedirectFrom(site: Site, text: string | null, url: string | null): string | null {
|
|
168
|
+
const collection = settingsCollection(site);
|
|
169
|
+
if (!collection || text === null || url === null) return null;
|
|
170
|
+
let doc: SettingsDocument;
|
|
171
|
+
try {
|
|
172
|
+
doc = parseAnyDocument(JSON.parse(text), site, collection) as SettingsDocument;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const list = doc.data.redirects ?? [];
|
|
177
|
+
const kept = list.filter((r) => r.from.toLowerCase() !== url.replace(/\/+$/, '').toLowerCase());
|
|
178
|
+
if (kept.length === list.length) return null;
|
|
179
|
+
return applyKeyedEdits(text, { title: doc.title, data: { ...doc.data, redirects: kept } }, collection);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What the move changes, from the store's own texts (`readText` by site-relative file;
|
|
184
|
+
* live mode's has the autosaves on top). `addRedirect`: whether `old → new` is added.
|
|
185
|
+
*/
|
|
186
|
+
export async function moveChanges(
|
|
187
|
+
site: Site,
|
|
188
|
+
plan: MovePlan,
|
|
189
|
+
documents: readonly DocumentEntry[],
|
|
190
|
+
readText: (file: string) => Promise<string | null>,
|
|
191
|
+
addRedirect: boolean,
|
|
192
|
+
): Promise<MoveChanges> {
|
|
193
|
+
const text = await readText(plan.from.file);
|
|
194
|
+
if (text === null) throw refused(404, 'not-found', `${site.collections[plan.from.collection]!.label}: no document "${plan.from.path}".`);
|
|
195
|
+
const collectionOf = (name: string) => site.collections[name]!;
|
|
196
|
+
// M8 Decision 14: its `$schema` path follows the file to its new folder.
|
|
197
|
+
const moved = stampSchema(relinkText(text, plan.move, site, collectionOf(plan.from.collection)).text, schemaRef(collectionOf(plan.to.collection), plan.to.file), { add: false });
|
|
198
|
+
const relinked: MoveChanges['relinked'] = [];
|
|
199
|
+
for (const d of documents) {
|
|
200
|
+
if (d.file === plan.from.file || d.file === SITE_SETTINGS_FILE) continue;
|
|
201
|
+
const text = await readText(d.file);
|
|
202
|
+
if (text === null) continue;
|
|
203
|
+
const after = relinkText(text, plan.move, site, collectionOf(d.collection));
|
|
204
|
+
if (after.links) relinked.push({ file: d.file, collection: d.collection, path: d.path, title: d.label, text: after.text });
|
|
205
|
+
}
|
|
206
|
+
const before = await readText(SITE_SETTINGS_FILE);
|
|
207
|
+
const after = settingsAfterMove(site, before, plan.move, addRedirect);
|
|
208
|
+
return {
|
|
209
|
+
moved,
|
|
210
|
+
relinked,
|
|
211
|
+
settings: after === null ? null : { before, after },
|
|
212
|
+
redirect: addRedirect ? { from: plan.move.fromUrl, to: plan.move.toUrl } : null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The route's answer for a move done. */
|
|
217
|
+
export function moveResponse(site: Site, plan: MovePlan, changes: MoveChanges): MoveResponse {
|
|
218
|
+
const relinked = changes.relinked.map(({ collection, path, title }) => ({ collection, path, title }));
|
|
219
|
+
if (changes.settings && relinkTouched(site, changes.settings, plan.move)) relinked.push({ collection: 'settings', path: 'site-settings', title: 'Site Settings' });
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
collection: plan.to.collection,
|
|
223
|
+
path: plan.to.path,
|
|
224
|
+
file: plan.to.file,
|
|
225
|
+
url: plan.to.url,
|
|
226
|
+
from: { path: plan.from.path, file: plan.from.file, url: plan.from.url },
|
|
227
|
+
relinked,
|
|
228
|
+
redirect: changes.redirect,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether Site Settings changed for its own links (not only its redirects). */
|
|
233
|
+
function relinkTouched(site: Site, settings: { before: string | null }, move: AddressMove): boolean {
|
|
234
|
+
const collection = settingsCollection(site);
|
|
235
|
+
return !!collection && settings.before !== null && relinkText(settings.before, move, site, collection).links > 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── The disk (local mode) ──────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/** Move on disk: the new file (exclusive), the rewritten files, Site Settings, then the old file goes. */
|
|
241
|
+
export async function moveOnDisk(site: Site, plan: MovePlan, changes: MoveChanges): Promise<void> {
|
|
242
|
+
const collection = site.collections[plan.to.collection]!;
|
|
243
|
+
const document = parseAnyDocument(JSON.parse(changes.moved), site, collection);
|
|
244
|
+
await createOnDisk(site, { ...plan.to, title: plan.title, text: changes.moved, document });
|
|
245
|
+
for (const r of changes.relinked) {
|
|
246
|
+
const confined = await confineOnDisk(site, r.file);
|
|
247
|
+
await writeAtomic(confined.absolute, r.text);
|
|
248
|
+
}
|
|
249
|
+
if (changes.settings) {
|
|
250
|
+
const file = join(site.root, SITE_SETTINGS_FILE);
|
|
251
|
+
if (changes.settings.before === null) {
|
|
252
|
+
await writeFile(file, changes.settings.after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
253
|
+
} else await writeAtomic(file, changes.settings.after);
|
|
254
|
+
}
|
|
255
|
+
await deleteOnDisk(site, { ...plan.from, entry: plan.from.entry });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── The route ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
|
|
261
|
+
new Response(JSON.stringify(body), {
|
|
262
|
+
status,
|
|
263
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/** What the route needs of the drafts store. */
|
|
267
|
+
export interface Moves {
|
|
268
|
+
move(request: MoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** POST /_caracms/move: a Request in, a Response out, through the mode's store (create.ts's handling, the same checks). */
|
|
272
|
+
export async function handleMove(request: Request, store: Moves, who: { person?: Person | null } = {}): Promise<Response> {
|
|
273
|
+
try {
|
|
274
|
+
if (request.method !== 'POST') throw new WriteRefused(405, 'The move route only takes POST.');
|
|
275
|
+
checkOrigin(request);
|
|
276
|
+
const type = request.headers.get('content-type') ?? '';
|
|
277
|
+
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
278
|
+
const length = Number(request.headers.get('content-length') ?? 0);
|
|
279
|
+
if (length > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
280
|
+
const raw = await request.text();
|
|
281
|
+
if (Buffer.byteLength(raw) > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
282
|
+
let body: unknown;
|
|
283
|
+
try {
|
|
284
|
+
body = JSON.parse(raw);
|
|
285
|
+
} catch {
|
|
286
|
+
throw refused(400, 'bad-request', 'Malformed body: it isn\'t valid JSON.');
|
|
287
|
+
}
|
|
288
|
+
const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: request.headers.get(RELEASE_HEADER) === '1', seq: seqOf(request) };
|
|
289
|
+
return json(200, await store.move(parseMoveBody(body), context));
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body }, error.status === 405 ? { Allow: 'POST' } : {});
|
|
292
|
+
console.error('[caracms] move failed:', error);
|
|
293
|
+
return json(500, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The shell's server render (Decision 2): the chrome is in the HTML before any script runs. */
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { createEditor } from '../client/editor.ts';
|
|
4
|
+
import { EditorApp } from '../client/shell/App.tsx';
|
|
5
|
+
import type { AdminProps } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
export function renderEditor(props: AdminProps): string {
|
|
8
|
+
return renderToString(<EditorApp editor={createEditor(props, { client: false })} />);
|
|
9
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace… on a file in the library (M6c G4, Decision 9;
|
|
3
|
+
* notes/2026-10-17-m6c-downloads-grid.md): every link to the old file, in every document
|
|
4
|
+
* the store lists (Site Settings and shared blocks included), points at the new one,
|
|
5
|
+
* `?download` and a fragment kept. The rules both stores apply, and what changes.
|
|
6
|
+
*
|
|
7
|
+
* - **Links are found by schema** (relink.ts → `refileText`, the walk a page move uses):
|
|
8
|
+
* a link field's or a button's `href`, and rich text's links. A plain-text mention of
|
|
9
|
+
* the address is never changed.
|
|
10
|
+
* - **Both keys are a file's** (caracms/files → `isFileKey`), and not the same key: the
|
|
11
|
+
* same bytes are the same key (content-addressed), so there's nothing to replace. The
|
|
12
|
+
* new file must be in the bucket already (uploaded first, through the upload route):
|
|
13
|
+
* the media route checks that before the store is asked (media/handler.ts).
|
|
14
|
+
* - **Nothing is deleted**: the old file stays in the bucket at its address.
|
|
15
|
+
*
|
|
16
|
+
* The stores apply the changes as a move's relinked files are applied: local mode writes
|
|
17
|
+
* each file (atomic, confined; no rollback, as a local move has none), live mode stores
|
|
18
|
+
* each as an autosave under this tab's locks and lands whole or rolls back
|
|
19
|
+
* (live/drafts-store.ts → `replaceFile`).
|
|
20
|
+
*/
|
|
21
|
+
import type { ResolvedSite } from '../../config.ts';
|
|
22
|
+
import type { DocumentEntry } from '../../content.ts';
|
|
23
|
+
import { isFileKey } from '../../files.ts';
|
|
24
|
+
import { refileText } from '../../relink.ts';
|
|
25
|
+
import type { ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
|
|
26
|
+
import { refused } from './create.ts';
|
|
27
|
+
|
|
28
|
+
type Site = Pick<ResolvedSite, 'collections' | 'blocks'>;
|
|
29
|
+
|
|
30
|
+
const SHAPE = '{ from, to }';
|
|
31
|
+
|
|
32
|
+
/** Validate a replace body's shape and keys. Throws WriteRefused 400. */
|
|
33
|
+
export function parseReplaceBody(body: unknown): ReplaceFileRequest {
|
|
34
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw refused(400, 'bad-request', `Malformed body: it must be a JSON object. Send ${SHAPE}.`);
|
|
35
|
+
const b = body as Record<string, unknown>;
|
|
36
|
+
const extra = Object.keys(b).filter((k) => k !== 'from' && k !== 'to');
|
|
37
|
+
if (extra.length) throw refused(400, 'bad-request', `Malformed body: unexpected keys. Send ${SHAPE}.`);
|
|
38
|
+
if (!isFileKey(b.from)) throw refused(400, 'bad-request', '"from" must be a file\'s key in the library (uploads/<hash>/<name>.<ext>).');
|
|
39
|
+
if (!isFileKey(b.to)) throw refused(400, 'bad-request', '"to" must be a file\'s key in the library (uploads/<hash>/<name>.<ext>).');
|
|
40
|
+
if (b.from === b.to) throw refused(400, 'same', "That's the same file.");
|
|
41
|
+
return { from: b.from, to: b.to };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Refiled {
|
|
45
|
+
file: string;
|
|
46
|
+
collection: string;
|
|
47
|
+
path: string;
|
|
48
|
+
title: string;
|
|
49
|
+
text: string;
|
|
50
|
+
links: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every document whose text changes, from the store's own texts (`readText` by site-relative file; live mode's has the autosaves on top). */
|
|
54
|
+
export async function replaceChanges(site: Site, request: ReplaceFileRequest, documents: readonly DocumentEntry[], readText: (file: string) => Promise<string | null>): Promise<Refiled[]> {
|
|
55
|
+
const out: Refiled[] = [];
|
|
56
|
+
for (const d of documents) {
|
|
57
|
+
const collection = site.collections[d.collection];
|
|
58
|
+
if (!collection) continue;
|
|
59
|
+
const text = await readText(d.file);
|
|
60
|
+
if (text === null) continue;
|
|
61
|
+
const after = refileText(text, request.from, request.to, site, collection);
|
|
62
|
+
if (after.links) out.push({ file: d.file, collection: d.collection, path: d.path, title: d.label, text: after.text, links: after.links });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const replaceResponse = (request: ReplaceFileRequest, changes: readonly Refiled[]): ReplaceFileResponse => ({
|
|
68
|
+
ok: true,
|
|
69
|
+
from: request.from,
|
|
70
|
+
to: request.to,
|
|
71
|
+
relinked: changes.map(({ collection, path, title, links }) => ({ collection, path, title, links })),
|
|
72
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Settings as they apply to the running site (M5a Decision 10): `editors` and
|
|
3
|
+
* `brand` are read from the **committed** copy, never an autosave, so a half-typed
|
|
4
|
+
* value is never site-wide.
|
|
5
|
+
*
|
|
6
|
+
* - Live mode: the `drafts` copy (`git.read`), as M4's whitelist reads `editors`
|
|
7
|
+
* (live/auth/allow.ts). Site Settings' autosave is swept at once (live/sweep.ts →
|
|
8
|
+
* `ready`), so a kept change applies on the sweep's next tick.
|
|
9
|
+
* - Local mode: the file on disk, read on every call, so it applies on the next request.
|
|
10
|
+
*
|
|
11
|
+
* The brand: Site Settings' `brand.name` (else "Site") and `brand.accent` (else the
|
|
12
|
+
* editor's neutral accent, `--accent` in tokens.css), and its `brand.logo` and
|
|
13
|
+
* `brand.icon` as signed URLs (M5b Decision 11).
|
|
14
|
+
*/
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { SITE_SETTINGS_FILE, type Brand, type ResolvedSite } from '../../config.ts';
|
|
18
|
+
import { brandFrom } from '../brand.ts';
|
|
19
|
+
import { editorMode, liveEditorOf } from './mode.ts';
|
|
20
|
+
|
|
21
|
+
export { brandFrom, DEFAULT_BRAND_NAME, editorBrand } from '../brand.ts';
|
|
22
|
+
|
|
23
|
+
/** Site Settings' committed text for the mode: the `drafts` copy (live) or the disk (local); null when there's none. */
|
|
24
|
+
export async function committedSettingsText(site: ResolvedSite): Promise<string | null> {
|
|
25
|
+
if (editorMode().kind === 'live') {
|
|
26
|
+
const live = await liveEditorOf(site);
|
|
27
|
+
return live!.runtime.git.read(SITE_SETTINGS_FILE);
|
|
28
|
+
}
|
|
29
|
+
return readFile(join(site.root, SITE_SETTINGS_FILE), 'utf8').catch(() => null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The brand as it applies now (Decision 10). */
|
|
33
|
+
export async function currentBrand(site: ResolvedSite): Promise<Brand> {
|
|
34
|
+
return brandFrom(await committedSettingsText(site), site);
|
|
35
|
+
}
|