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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /_caracms/form`: every form's destination (M6 Decisions 5 and 6; the template's
|
|
3
|
+
* `server/forms.mjs`, `forms.md` → The endpoint and Files: the wire contract).
|
|
4
|
+
*
|
|
5
|
+
* **Public.** The guard lets it through exactly (live/auth/guard.ts → `isLetThrough`)
|
|
6
|
+
* and the unconfigured server's 503 exempts it (editor/middleware.ts), so it works in
|
|
7
|
+
* local mode, live mode, and on a production server whose editor isn't set up. No
|
|
8
|
+
* session, no same-origin check.
|
|
9
|
+
*
|
|
10
|
+
* In order, and **nothing is written until every check has passed**:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The rate limit's peek** for a body that can be large (multipart on a site with a
|
|
13
|
+
* files field, or a declared length over 256 KB): an IP already at the limit gets the
|
|
14
|
+
* 429 and the body is never read.
|
|
15
|
+
* 2. **The body**, read up to its cap: 256 KB for any body without files; 25 MiB +
|
|
16
|
+
* 256 KB for multipart only when some form has a files field (the config decides,
|
|
17
|
+
* not the client). A declared `Content-Length` over the cap is refused before a byte
|
|
18
|
+
* is read, and reading stops at the chunk that passes it: 413, which says which limit.
|
|
19
|
+
* 3. **The return page** (`return`), accepted only as `^/[a-z0-9/-]*$` not starting
|
|
20
|
+
* `//`, else `/`. **A known form** (`form`), else 400: a bad id is a broken page.
|
|
21
|
+
* 4. **The honeypot** (`fax`, rules.ts): filled → the success reply, nothing stored.
|
|
22
|
+
* 5. **Each field**, in the form's order: required unless optional, an email's shape, a
|
|
23
|
+
* select's options, the files (up to 5, the extension list, the declared type fits
|
|
24
|
+
* the extension). Every problem comes back in one 400.
|
|
25
|
+
* 6. **The rate limit's hit** (rate-limit.ts): 5 per IP per 10 minutes, 429 past it.
|
|
26
|
+
* A hit is counted here, so a stored submission and a Turnstile refusal both count;
|
|
27
|
+
* a small body that fails 4 or 5 doesn't (a body over 256 KB does).
|
|
28
|
+
* 7. **Turnstile**, when `TURNSTILE_SECRET_KEY` is set: no token or a refused one is a
|
|
29
|
+
* 403; Cloudflare unreachable fails open, logged. Unset: accepted, with a warning.
|
|
30
|
+
* 8. **Store** (store.ts), then **email** (email.ts), then **record** the email's outcome
|
|
31
|
+
* on the submission, then reply. A storage failure is a 502; an email failure never
|
|
32
|
+
* fails the visitor.
|
|
33
|
+
*
|
|
34
|
+
* Replies: `Accept: application/json` gets `{ ok: true }` or `{ error }`; a no-JS post
|
|
35
|
+
* gets a 303 to `<return>?sent=1`, or the error as plain text. Nothing about the visitor
|
|
36
|
+
* (IP, user agent) is stored.
|
|
37
|
+
*/
|
|
38
|
+
import type { ResolvedSite } from '../config.ts';
|
|
39
|
+
import { publicOrigin } from '../live/auth/index.ts';
|
|
40
|
+
import { clientIp } from '../live/auth/request.ts';
|
|
41
|
+
import { readCapped } from '../editor/server/media/upload.ts';
|
|
42
|
+
import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
|
|
43
|
+
import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
|
|
44
|
+
import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
|
|
45
|
+
import { cleanFileName, fileExtension, readSubmissionsConfig, submissionStore, type NewFile, type SubmissionStore } from './store.ts';
|
|
46
|
+
import { shownFileName } from './rules.ts';
|
|
47
|
+
|
|
48
|
+
type Env = Record<string, string | undefined>;
|
|
49
|
+
|
|
50
|
+
export const FORM_ROUTE = '/_caracms/form';
|
|
51
|
+
|
|
52
|
+
/** The cap for every body without files, and for the fields of one with them. */
|
|
53
|
+
export const BODY_LIMIT = 256 * 1024;
|
|
54
|
+
/** The cap on a multipart body when the site takes files: the files' 25 MiB plus the fields' 256 KB. */
|
|
55
|
+
export const UPLOAD_BODY_LIMIT = BODY_LIMIT + MAX_FILE_BYTES;
|
|
56
|
+
|
|
57
|
+
export const TOO_LARGE_MESSAGE = `That's too much to send: attachments can come to ${MAX_FILE_BYTES / 1024 / 1024} MB in all. Send fewer or smaller files, or email them to us.`;
|
|
58
|
+
export const TOO_LONG_MESSAGE = "That's too much to send in one message. Shorten it, or email us.";
|
|
59
|
+
export const RATE_LIMITED_MESSAGE = "You've sent a few messages in a row. Please wait a few minutes and try again, or email us directly.";
|
|
60
|
+
export const TURNSTILE_MESSAGE = "We couldn't confirm you're a person. Please try again, or email us directly.";
|
|
61
|
+
export const STORE_FAILED_MESSAGE = 'Could not send your message.';
|
|
62
|
+
export const UNKNOWN_FORM_MESSAGE = "This form isn't on the site any more. Reload the page and try again.";
|
|
63
|
+
export const BAD_EMAIL_MESSAGE = "That email address doesn't look right.";
|
|
64
|
+
|
|
65
|
+
export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
66
|
+
const TURNSTILE_TIMEOUT_MS = 5_000;
|
|
67
|
+
|
|
68
|
+
/** `SITE_URL` as an address (an http(s) origin), or undefined when it's unset or isn't one. */
|
|
69
|
+
function siteUrlFromEnv(env: Env): URL | undefined {
|
|
70
|
+
const raw = env['SITE_URL']?.trim();
|
|
71
|
+
if (!raw) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(raw);
|
|
74
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url : undefined;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { cleanReturn };
|
|
81
|
+
|
|
82
|
+
export interface FormContext {
|
|
83
|
+
site: Pick<ResolvedSite, 'root' | 'forms' | 'timeZone'>;
|
|
84
|
+
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
|
|
85
|
+
clientAddress?: () => string | undefined;
|
|
86
|
+
env?: Env;
|
|
87
|
+
/** Astro's `site` (the route passes `context.site`): the public origin for the email's editor link. */
|
|
88
|
+
astroSite?: URL;
|
|
89
|
+
/** Cloudflare's and Resend's `fetch`, and the bucket's (tests). */
|
|
90
|
+
fetch?: typeof fetch;
|
|
91
|
+
now?: () => Date;
|
|
92
|
+
log?: (line: string) => void;
|
|
93
|
+
limiter?: FormRateLimiter;
|
|
94
|
+
/** Tests: the store, the settings reader. */
|
|
95
|
+
store?: SubmissionStore;
|
|
96
|
+
readSettings?: () => Promise<FormSettings>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A body's values as one `get(name)`, plus its files by name (multipart read with files on). */
|
|
100
|
+
interface FormBody {
|
|
101
|
+
get(name: string): string;
|
|
102
|
+
files(name: string): File[];
|
|
103
|
+
/** The raw body's size. */
|
|
104
|
+
bytes: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class BodyTooLarge extends Error {
|
|
108
|
+
readonly upload: boolean;
|
|
109
|
+
constructor(upload: boolean) {
|
|
110
|
+
super('body over its cap');
|
|
111
|
+
this.upload = upload;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readFormBody(request: Request, takesFiles: boolean): Promise<FormBody> {
|
|
116
|
+
const type = (request.headers.get('content-type') ?? '').toLowerCase();
|
|
117
|
+
const multipart = type.includes('multipart/form-data');
|
|
118
|
+
const upload = takesFiles && multipart;
|
|
119
|
+
const read = await readCapped(request, upload ? UPLOAD_BODY_LIMIT : BODY_LIMIT);
|
|
120
|
+
if (!read.ok) throw new BodyTooLarge(upload);
|
|
121
|
+
const raw = read.bytes;
|
|
122
|
+
const text = () => new TextDecoder().decode(raw);
|
|
123
|
+
if (type.includes('application/x-www-form-urlencoded')) {
|
|
124
|
+
const params = new URLSearchParams(text());
|
|
125
|
+
return { get: (name) => (params.get(name) ?? '').trim(), files: () => [], bytes: raw.byteLength };
|
|
126
|
+
}
|
|
127
|
+
if (type.includes('application/json')) {
|
|
128
|
+
const parsed = JSON.parse(text() || '{}') as unknown;
|
|
129
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('a JSON body must be an object');
|
|
130
|
+
const o = parsed as Record<string, unknown>;
|
|
131
|
+
const get = (name: string) => {
|
|
132
|
+
const v = Object.hasOwn(o, name) ? o[name] : undefined;
|
|
133
|
+
if (typeof v === 'string') return v.trim();
|
|
134
|
+
if (typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))) return String(v);
|
|
135
|
+
return '';
|
|
136
|
+
};
|
|
137
|
+
return { get, files: () => [], bytes: raw.byteLength };
|
|
138
|
+
}
|
|
139
|
+
if (multipart) {
|
|
140
|
+
// Node's own multipart parser, through the fetch API's FormData.
|
|
141
|
+
const data = await new Response(raw as BodyInit, { headers: { 'content-type': request.headers.get('content-type') ?? '' } }).formData();
|
|
142
|
+
let fileBytes = 0;
|
|
143
|
+
for (const [, value] of data) if (typeof value !== 'string') fileBytes += value.size;
|
|
144
|
+
if (upload && fileBytes > MAX_FILE_BYTES) throw new BodyTooLarge(true);
|
|
145
|
+
if (upload && raw.byteLength - fileBytes > BODY_LIMIT) throw new BodyTooLarge(false);
|
|
146
|
+
const get = (name: string) => {
|
|
147
|
+
const v = data.get(name);
|
|
148
|
+
return typeof v === 'string' ? v.trim() : '';
|
|
149
|
+
};
|
|
150
|
+
// A browser's empty file input (no name, no bytes) is no file. On a site that takes no files, no part is one.
|
|
151
|
+
const files = (name: string) => (upload ? data.getAll(name).filter((v): v is File => typeof v !== 'string' && (v.name !== '' || v.size > 0)) : []);
|
|
152
|
+
return { get, files, bytes: raw.byteLength };
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`unsupported content type: ${type || '(none)'}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The declared type, bare: lower case, no parameters. */
|
|
158
|
+
const bareType = (type: string) => type.split(';')[0]!.trim().toLowerCase();
|
|
159
|
+
|
|
160
|
+
/** The posted files of a files field: what to store, or the problems, each naming the file. */
|
|
161
|
+
export function checkFiles(posted: readonly File[]): { files: { name: string; type: string; blob: File }[]; problems: string[] } {
|
|
162
|
+
if (posted.length > MAX_FILES) return { files: [], problems: [`Attach up to ${MAX_FILES} files (you chose ${posted.length}).`] };
|
|
163
|
+
const files: { name: string; type: string; blob: File }[] = [];
|
|
164
|
+
const problems: string[] = [];
|
|
165
|
+
for (const file of posted) {
|
|
166
|
+
const name = cleanFileName(file.name);
|
|
167
|
+
const shown = shownFileName(name) || 'A file';
|
|
168
|
+
const ext = fileExtension(name);
|
|
169
|
+
const allowed = Object.hasOwn(FILE_TYPES, ext) ? FILE_TYPES[ext] : undefined;
|
|
170
|
+
const type = bareType(file.type ?? '');
|
|
171
|
+
if (!allowed) problems.push(`${shown}: we can't take that kind of file. Send ${FILE_KINDS} files.`);
|
|
172
|
+
else if (type && type !== 'application/octet-stream' && !allowed.includes(type)) {
|
|
173
|
+
problems.push(`${shown}: the file isn't what its name says (a .${ext} file). Save it again as a .${ext}, or send another.`);
|
|
174
|
+
} else files.push({ name, type, blob: file });
|
|
175
|
+
}
|
|
176
|
+
return { files, problems };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One submission against its form: what to store, or the 400's sentence (every problem in it). */
|
|
180
|
+
export function vetFields(form: ResolvedForm, body: Pick<FormBody, 'get' | 'files'>): { error: string } | { fields: Record<string, string>; files: { name: string; type: string; blob: File }[] } {
|
|
181
|
+
const fields: Record<string, string> = {};
|
|
182
|
+
const missing: string[] = [];
|
|
183
|
+
const problems: string[] = [];
|
|
184
|
+
let files: { name: string; type: string; blob: File }[] = [];
|
|
185
|
+
for (const field of form.fields) {
|
|
186
|
+
if (field.type === 'files') {
|
|
187
|
+
const posted = body.files(field.name);
|
|
188
|
+
if (posted.length === 0 && !field.optional) missing.push(field.label);
|
|
189
|
+
const checked = checkFiles(posted);
|
|
190
|
+
problems.push(...checked.problems);
|
|
191
|
+
files = checked.files;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const value = body.get(field.name);
|
|
195
|
+
fields[field.name] = value;
|
|
196
|
+
if (!value) {
|
|
197
|
+
if (!field.optional) missing.push(field.label);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (field.type === 'email' && !looksLikeEmail(value)) problems.push(BAD_EMAIL_MESSAGE);
|
|
201
|
+
if (field.type === 'select' && !field.options?.some((o) => o.value === value)) problems.push(`${field.label}: choose one of the options.`);
|
|
202
|
+
}
|
|
203
|
+
const sentences = [...(missing.length ? [`Missing: ${missing.join(', ')}.`] : []), ...problems];
|
|
204
|
+
return sentences.length ? { error: sentences.join(' ') } : { fields, files };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Cloudflare's check of the widget's token. Unreachable → true (fail open, logged). */
|
|
208
|
+
export async function verifyTurnstile(input: { token: string; secret: string; ip: string; fetch: typeof fetch; log: (line: string) => void }): Promise<boolean> {
|
|
209
|
+
try {
|
|
210
|
+
const res = await input.fetch(TURNSTILE_VERIFY_URL, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ secret: input.secret, response: input.token, remoteip: input.ip }),
|
|
214
|
+
signal: AbortSignal.timeout(TURNSTILE_TIMEOUT_MS),
|
|
215
|
+
});
|
|
216
|
+
const data = (await res.json()) as { success?: boolean; 'error-codes'?: string[] };
|
|
217
|
+
if (data.success !== true) input.log(`[caracms] forms: Turnstile refused a token (${(data['error-codes'] ?? []).join(', ') || 'no reason given'}).`);
|
|
218
|
+
return data.success === true;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
input.log(`[caracms] forms: couldn't reach Turnstile's check, so the submission was let through: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Answer one request to `/_caracms/form`. */
|
|
226
|
+
export async function handleForm(request: Request, ctx: FormContext): Promise<Response> {
|
|
227
|
+
const env = ctx.env ?? process.env;
|
|
228
|
+
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
229
|
+
const now = ctx.now ?? (() => new Date());
|
|
230
|
+
const doFetch = ctx.fetch ?? ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args));
|
|
231
|
+
const limiter = ctx.limiter ?? formLimiter(env);
|
|
232
|
+
const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
|
|
233
|
+
let back = '/';
|
|
234
|
+
|
|
235
|
+
const reply = (status: number, body: { ok: true } | { error: string }, extra: Record<string, string> = {}): Response => {
|
|
236
|
+
const headers: Record<string, string> = { 'Cache-Control': 'private, no-store', ...extra };
|
|
237
|
+
if (wantsJson) return new Response(JSON.stringify(body), { status, headers: { ...headers, 'Content-Type': 'application/json; charset=utf-8' } });
|
|
238
|
+
if (status < 400) return new Response(null, { status: 303, headers: { ...headers, Location: `${back}?sent=1` } });
|
|
239
|
+
return new Response('error' in body ? body.error : 'Bad request.', { status, headers: { ...headers, 'Content-Type': 'text/plain; charset=utf-8' } });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (request.method !== 'POST') {
|
|
243
|
+
await request.body?.cancel().catch(() => {});
|
|
244
|
+
return reply(405, { error: 'This address takes a form post.' }, { Allow: 'POST' });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const ip = clientIp(request, ctx.clientAddress ?? (() => undefined));
|
|
248
|
+
const takesFiles = formsAcceptFiles(ctx.site.forms);
|
|
249
|
+
const declared = Number(request.headers.get('content-length') ?? NaN);
|
|
250
|
+
const multipart = (request.headers.get('content-type') ?? '').toLowerCase().includes('multipart/form-data');
|
|
251
|
+
const canBeLarge = (takesFiles && multipart) || (Number.isFinite(declared) && declared > BODY_LIMIT);
|
|
252
|
+
if (canBeLarge && (await limiter.peek(ip))) {
|
|
253
|
+
await request.body?.cancel().catch(() => {});
|
|
254
|
+
return reply(429, { error: RATE_LIMITED_MESSAGE });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let body: FormBody;
|
|
258
|
+
try {
|
|
259
|
+
body = await readFormBody(request, takesFiles);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof BodyTooLarge) {
|
|
262
|
+
log(`[caracms] forms: refused a body over its cap (${error.upload ? 'attachments' : 'fields'}).`);
|
|
263
|
+
return reply(413, { error: error.upload ? TOO_LARGE_MESSAGE : TOO_LONG_MESSAGE });
|
|
264
|
+
}
|
|
265
|
+
log(`[caracms] forms: an unreadable body (${error instanceof Error ? error.message : String(error)}).`);
|
|
266
|
+
return reply(400, { error: 'Expected form data.' });
|
|
267
|
+
}
|
|
268
|
+
back = cleanReturn(body.get('return'));
|
|
269
|
+
// A large body that ends in a refusal or the honeypot still counts, so repeated large posts trip the limit.
|
|
270
|
+
const large = body.bytes > BODY_LIMIT;
|
|
271
|
+
const countIfLarge = async () => {
|
|
272
|
+
if (large) await limiter.hit(ip);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const formId = body.get('form');
|
|
276
|
+
const form = Object.hasOwn(ctx.site.forms, formId) ? ctx.site.forms[formId]! : null;
|
|
277
|
+
if (!form) {
|
|
278
|
+
await countIfLarge();
|
|
279
|
+
log(`[caracms] forms: a post for an unknown form (${JSON.stringify(formId.slice(0, 64))}).`);
|
|
280
|
+
return reply(400, { error: UNKNOWN_FORM_MESSAGE });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The honeypot: answer as if it worked (telling a bot it failed teaches it to retry); nothing is stored, its files included.
|
|
284
|
+
if (body.get(HONEYPOT)) {
|
|
285
|
+
await countIfLarge();
|
|
286
|
+
return reply(200, { ok: true });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const vetted = vetFields(form, body);
|
|
290
|
+
if ('error' in vetted) {
|
|
291
|
+
await countIfLarge();
|
|
292
|
+
return reply(400, { error: vetted.error });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Looked at here, counted once the submission is stored (below) or Turnstile refuses it: a try the server
|
|
296
|
+
// failed to store (a 502) isn't the visitor's to pay for (Decision 5 counts a stored submission).
|
|
297
|
+
if (await limiter.peek(ip)) return reply(429, { error: RATE_LIMITED_MESSAGE });
|
|
298
|
+
|
|
299
|
+
const secret = env['TURNSTILE_SECRET_KEY']?.trim() ?? '';
|
|
300
|
+
if (secret) {
|
|
301
|
+
const token = body.get('cf-turnstile-response');
|
|
302
|
+
if (!token || !(await verifyTurnstile({ token, secret, ip, fetch: doFetch, log }))) {
|
|
303
|
+
await limiter.hit(ip);
|
|
304
|
+
return reply(403, { error: TURNSTILE_MESSAGE });
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
log('[caracms] forms: TURNSTILE_SECRET_KEY is not set, so this submission was accepted without a bot check.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// STORE: what makes it received. The store writes the files, then the JSON, and removes what it wrote on a failure.
|
|
311
|
+
let store: SubmissionStore;
|
|
312
|
+
if (ctx.store) store = ctx.store;
|
|
313
|
+
else {
|
|
314
|
+
const chosen = submissionStore(readSubmissionsConfig(ctx.site.root, env), ctx.fetch);
|
|
315
|
+
if ('kind' in chosen) {
|
|
316
|
+
log(`[caracms] forms: a ${form.id} submission couldn't be stored. ${chosen.message}`);
|
|
317
|
+
return reply(502, { error: STORE_FAILED_MESSAGE });
|
|
318
|
+
}
|
|
319
|
+
store = chosen;
|
|
320
|
+
}
|
|
321
|
+
let submission;
|
|
322
|
+
try {
|
|
323
|
+
const files: NewFile[] = await Promise.all(vetted.files.map(async (f) => ({ name: f.name, type: f.type, data: new Uint8Array(await f.blob.arrayBuffer()) })));
|
|
324
|
+
submission = await store.create({ form: form.id, receivedAt: now().toISOString(), fields: vetted.fields, ...(files.length ? { files } : {}) });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
log(`[caracms] forms: couldn't store a ${form.id} submission: ${error instanceof Error ? error.message : String(error)}`);
|
|
327
|
+
return reply(502, { error: STORE_FAILED_MESSAGE });
|
|
328
|
+
}
|
|
329
|
+
await limiter.hit(ip);
|
|
330
|
+
|
|
331
|
+
// EMAIL, then RECORD its outcome. Neither changes the visitor's reply.
|
|
332
|
+
let email;
|
|
333
|
+
try {
|
|
334
|
+
const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
|
|
335
|
+
const siteUrl = publicOrigin(ctx.astroSite ?? siteUrlFromEnv(env), env);
|
|
336
|
+
email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
|
|
337
|
+
} catch (error) {
|
|
338
|
+
email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
|
|
339
|
+
}
|
|
340
|
+
if (email.status === 'failed') log(`[caracms] forms: ${form.id} submission ${submission.id}: the email failed: ${email.error}`);
|
|
341
|
+
try {
|
|
342
|
+
await store.update(submission.id, { email });
|
|
343
|
+
} catch (error) {
|
|
344
|
+
log(`[caracms] forms: couldn't record the email's outcome on ${submission.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
345
|
+
}
|
|
346
|
+
return reply(200, { ok: true });
|
|
347
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox's routes (M6 Decision 9; Contracts → Routes), one handler behind
|
|
3
|
+
* `/_caracms/submissions/[...path]` (routes/submissions.ts):
|
|
4
|
+
*
|
|
5
|
+
* | Route | Does |
|
|
6
|
+
* |---|---|
|
|
7
|
+
* | `GET /_caracms/submissions[?before=<id>]` | `{ items: SubmissionRow[], older, unread }`, newest first, 50 a page |
|
|
8
|
+
* | `GET /_caracms/submissions/<id>` | `{ submission, form: { label, fields } \| null, unread }`. Changes nothing |
|
|
9
|
+
* | `POST …/<id>/read` · `/unread` · `/delete` | 204, the unread count in `X-Caracms-Unread`. `delete` needs `{ "confirm": true }` |
|
|
10
|
+
* | `GET …/<id>/files/<n>` | The file, always as an attachment |
|
|
11
|
+
*
|
|
12
|
+
* - **A session in live mode** (the guard's prefix rule, checked again here), and for a
|
|
13
|
+
* POST the same origin. **No lock**: there are no roles, and nothing here is content.
|
|
14
|
+
* - **Every reply is `Cache-Control: private, no-store`** and `X-Robots-Tag: noindex, nofollow`.
|
|
15
|
+
* - **Ids and file numbers are checked before the store is asked**: an id that isn't one
|
|
16
|
+
* the store mints, or an `n` that isn't 1–999, is a 404 with no read. The stored key is
|
|
17
|
+
* checked again by the store (`isFileKey`), so a tampered JSON can't reach another file.
|
|
18
|
+
* - **A download's type comes from the stored key's extension**, never the visitor's
|
|
19
|
+
* declared type; `Content-Disposition: attachment` with the name escaped (`filename*`
|
|
20
|
+
* in UTF-8), `nosniff`.
|
|
21
|
+
* - **The production server without its bucket**: 503 `{ error, code: 'submissions-unset', missing }`.
|
|
22
|
+
* - **Delete removes the files, then the JSON** (store.ts), so a failure part-way leaves it listed.
|
|
23
|
+
*/
|
|
24
|
+
import type { ResolvedSite } from '../config.ts';
|
|
25
|
+
import { checkOrigin, WriteRefused } from '../editor/server/write.ts';
|
|
26
|
+
import { FILE_TYPES, isSubmissionId, senderName, shownFileName, submissionSnippet, type ResolvedFormField } from './rules.ts';
|
|
27
|
+
import { fileExtension, readSubmissionsConfig, submissionStore, type StoredSubmission, type SubmissionStore } from './store.ts';
|
|
28
|
+
|
|
29
|
+
type Env = Record<string, string | undefined>;
|
|
30
|
+
|
|
31
|
+
export const SUBMISSIONS_PREFIX = '/_caracms/submissions';
|
|
32
|
+
/** The unread count on a 204 (read, unread, delete): Decision 9's "with every submissions response". */
|
|
33
|
+
export const UNREAD_HEADER = 'X-Caracms-Unread';
|
|
34
|
+
|
|
35
|
+
/** One row of the inbox's list. */
|
|
36
|
+
export interface SubmissionRow {
|
|
37
|
+
id: string;
|
|
38
|
+
form: string;
|
|
39
|
+
/** The form's label, or its id when the form is no longer in the config. */
|
|
40
|
+
formLabel: string;
|
|
41
|
+
/** `senderName`: the name fields, else the address. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** The visitor's address as sent. */
|
|
44
|
+
email: string;
|
|
45
|
+
/** The first textarea's text, one line. */
|
|
46
|
+
snippet: string;
|
|
47
|
+
receivedAt: string;
|
|
48
|
+
readAt: string | null;
|
|
49
|
+
/** How many files came with it. */
|
|
50
|
+
files: number;
|
|
51
|
+
/** The email's outcome, or null when it's unknown (the server died between storing and recording). */
|
|
52
|
+
emailStatus: 'sent' | 'failed' | 'skipped' | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SubmissionsListResponse {
|
|
56
|
+
items: SubmissionRow[];
|
|
57
|
+
/** The cursor for "Older" (`?before=`), or null on the last page. */
|
|
58
|
+
older: string | null;
|
|
59
|
+
unread: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SubmissionResponse {
|
|
63
|
+
submission: StoredSubmission;
|
|
64
|
+
/** The form as the config has it now, or null when it's gone (show the stored fields by name). */
|
|
65
|
+
form: { label: string; fields: ResolvedFormField[] } | null;
|
|
66
|
+
unread: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SubmissionsUnset {
|
|
70
|
+
error: string;
|
|
71
|
+
code: 'submissions-unset';
|
|
72
|
+
missing: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface InboxContext {
|
|
76
|
+
site: Pick<ResolvedSite, 'root' | 'forms'>;
|
|
77
|
+
mode: 'local' | 'live';
|
|
78
|
+
/** The signed-in person's address (live mode), or null. */
|
|
79
|
+
person: string | null;
|
|
80
|
+
env?: Env;
|
|
81
|
+
log?: (line: string) => void;
|
|
82
|
+
/** Tests: the store itself. */
|
|
83
|
+
store?: SubmissionStore;
|
|
84
|
+
fetch?: typeof fetch;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const HEADERS = { 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex, nofollow' };
|
|
88
|
+
const json = (status: number, body: unknown, extra: Record<string, string> = {}) =>
|
|
89
|
+
new Response(JSON.stringify(body), { status, headers: { ...HEADERS, 'Content-Type': 'application/json; charset=utf-8', ...extra } });
|
|
90
|
+
const notFound = () => json(404, { error: "There's no such submission." });
|
|
91
|
+
|
|
92
|
+
export function rowOf(site: Pick<ResolvedSite, 'forms'>, s: StoredSubmission): SubmissionRow {
|
|
93
|
+
const form = Object.hasOwn(site.forms, s.form) ? site.forms[s.form]! : null;
|
|
94
|
+
const fields = s.fields ?? {};
|
|
95
|
+
return {
|
|
96
|
+
id: s.id,
|
|
97
|
+
form: s.form,
|
|
98
|
+
formLabel: form?.label ?? s.form,
|
|
99
|
+
name: senderName(form, fields),
|
|
100
|
+
email: typeof fields.email === 'string' ? fields.email : '',
|
|
101
|
+
snippet: submissionSnippet(form, fields),
|
|
102
|
+
receivedAt: s.receivedAt,
|
|
103
|
+
readAt: s.readAt ?? null,
|
|
104
|
+
files: s.files?.length ?? 0,
|
|
105
|
+
emailStatus: s.email?.status ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The store for the environment, or the 503 to answer with. */
|
|
110
|
+
export function inboxStore(ctx: Pick<InboxContext, 'site' | 'env' | 'store' | 'fetch'>): SubmissionStore | SubmissionsUnset {
|
|
111
|
+
if (ctx.store) return ctx.store;
|
|
112
|
+
const chosen = submissionStore(readSubmissionsConfig(ctx.site.root, ctx.env ?? process.env), ctx.fetch);
|
|
113
|
+
return 'kind' in chosen ? { error: chosen.message, code: 'submissions-unset', missing: chosen.missing } : chosen;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `Content-Disposition` for a visitor's file name: an ASCII fallback, and `filename*` in UTF-8 when it isn't plain ASCII. */
|
|
117
|
+
export function attachmentHeader(name: string): string {
|
|
118
|
+
const base = shownFileName(name) || 'file';
|
|
119
|
+
const ascii = base.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
120
|
+
const plain = ascii === base;
|
|
121
|
+
return plain ? `attachment; filename="${ascii}"` : `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A download's type, from the stored key's extension. */
|
|
125
|
+
export const downloadType = (key: string): string => FILE_TYPES[fileExtension(key)]?.[0] ?? 'application/octet-stream';
|
|
126
|
+
|
|
127
|
+
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
|
128
|
+
const text = await request.text().catch(() => '');
|
|
129
|
+
if (text.length > 4096) return {};
|
|
130
|
+
try {
|
|
131
|
+
const value = JSON.parse(text || '{}') as unknown;
|
|
132
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Answer one request under `/_caracms/submissions`. */
|
|
139
|
+
export async function handleSubmissions(request: Request, ctx: InboxContext): Promise<Response> {
|
|
140
|
+
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
141
|
+
const url = new URL(request.url);
|
|
142
|
+
const method = request.method.toUpperCase();
|
|
143
|
+
if (url.pathname !== SUBMISSIONS_PREFIX && !url.pathname.startsWith(`${SUBMISSIONS_PREFIX}/`)) return notFound();
|
|
144
|
+
const parts = url.pathname.slice(SUBMISSIONS_PREFIX.length).split('/').filter(Boolean);
|
|
145
|
+
const only = (allowed: string) => (method === allowed ? null : json(405, { error: `This route takes ${allowed}.` }, { Allow: allowed }));
|
|
146
|
+
try {
|
|
147
|
+
// Defence in depth: the guard did both in live mode.
|
|
148
|
+
if (ctx.mode === 'live' && !ctx.person) return json(401, { error: 'signed-out', code: 'signed-out' });
|
|
149
|
+
if (method !== 'GET' && method !== 'HEAD') checkOrigin(request);
|
|
150
|
+
|
|
151
|
+
const [id, action, n, extra] = parts;
|
|
152
|
+
// Checked before the store is asked: an id it never minted, or a route that isn't one, is a 404.
|
|
153
|
+
if (id !== undefined && !isSubmissionId(id)) return notFound();
|
|
154
|
+
const shapeOk =
|
|
155
|
+
id === undefined || action === undefined || ((action === 'read' || action === 'unread' || action === 'delete') && n === undefined) || (action === 'files' && n !== undefined && /^[1-9]\d{0,2}$/.test(n) && extra === undefined);
|
|
156
|
+
if (!shapeOk) return notFound();
|
|
157
|
+
|
|
158
|
+
const store = inboxStore(ctx);
|
|
159
|
+
if ('code' in store) return json(503, store);
|
|
160
|
+
|
|
161
|
+
if (id === undefined) {
|
|
162
|
+
const refused = only('GET');
|
|
163
|
+
if (refused) return refused;
|
|
164
|
+
const before = url.searchParams.get('before');
|
|
165
|
+
if (before !== null && !isSubmissionId(before)) return json(400, { error: '`before` must be a submission id.' });
|
|
166
|
+
const page = await store.list(before ? { before } : {});
|
|
167
|
+
return json(200, { items: page.items.map((s) => rowOf(ctx.site, s)), older: page.older, unread: await store.unreadCount() } satisfies SubmissionsListResponse);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (action === undefined) {
|
|
171
|
+
const refused = only('GET');
|
|
172
|
+
if (refused) return refused;
|
|
173
|
+
const submission = await store.get(id);
|
|
174
|
+
if (!submission) return notFound();
|
|
175
|
+
const form = Object.hasOwn(ctx.site.forms, submission.form) ? ctx.site.forms[submission.form]! : null;
|
|
176
|
+
return json(200, { submission, form: form ? { label: form.label, fields: form.fields } : null, unread: await store.unreadCount() } satisfies SubmissionResponse);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (action === 'files') {
|
|
180
|
+
const refused = only('GET');
|
|
181
|
+
if (refused) return refused;
|
|
182
|
+
const found = await store.getFile(id, Number(n));
|
|
183
|
+
if (!found) return notFound();
|
|
184
|
+
return new Response(found.body as BodyInit, {
|
|
185
|
+
status: 200,
|
|
186
|
+
headers: {
|
|
187
|
+
...HEADERS,
|
|
188
|
+
'Content-Type': downloadType(found.file.key),
|
|
189
|
+
'Content-Disposition': attachmentHeader(found.file.name || found.file.key),
|
|
190
|
+
'Content-Length': String(found.body.byteLength),
|
|
191
|
+
'X-Content-Type-Options': 'nosniff',
|
|
192
|
+
'Content-Security-Policy': "default-src 'none'; sandbox",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const refused = only('POST');
|
|
198
|
+
if (refused) return refused;
|
|
199
|
+
if (action === 'delete') {
|
|
200
|
+
const body = await readJsonBody(request);
|
|
201
|
+
if (body.confirm !== true) return json(400, { error: 'Deleting a submission is permanent: send { "confirm": true }.' });
|
|
202
|
+
if (!(await store.delete(id))) return notFound();
|
|
203
|
+
} else {
|
|
204
|
+
const updated = await store.update(id, { readAt: action === 'read' ? new Date().toISOString() : null });
|
|
205
|
+
if (!updated) return notFound();
|
|
206
|
+
}
|
|
207
|
+
return new Response(null, { status: 204, headers: { ...HEADERS, [UNREAD_HEADER]: String(await store.unreadCount()) } });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
|
|
210
|
+
log(`[caracms] ${url.pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
211
|
+
return json(502, { error: "The submissions couldn't be read just now. Try again in a moment." });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The inbox's state for /admin's first paint and the documents list (Decision 9): the unread count, or why there's none. */
|
|
216
|
+
export type SubmissionsSetup = { unread: number } | { unread: null; error: string; code: 'submissions-unset' | 'submissions-unreadable'; missing: string[] };
|
|
217
|
+
|
|
218
|
+
export async function submissionsSetup(ctx: Pick<InboxContext, 'site' | 'env' | 'store' | 'fetch' | 'log'>): Promise<SubmissionsSetup> {
|
|
219
|
+
const store = inboxStore(ctx);
|
|
220
|
+
if ('code' in store) return { unread: null, error: store.error, code: store.code, missing: store.missing };
|
|
221
|
+
try {
|
|
222
|
+
return { unread: await store.unreadCount() };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
(ctx.log ?? ((line: string) => console.error(line)))(`[caracms] couldn't count the unread submissions: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
return { unread: null, error: "The submissions couldn't be read just now.", code: 'submissions-unreadable', missing: [] };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** How long the editor's own pages wait for the unread count before going on without it. */
|
|
230
|
+
const SETUP_WAIT_MS = 1500;
|
|
231
|
+
/** How long a count that failed or timed out is not asked for again by those pages. */
|
|
232
|
+
const SETUP_RETRY_MS = 30_000;
|
|
233
|
+
/** Per store: when the last count failed or timed out (the editor pages' memory of it). */
|
|
234
|
+
const setupFailures = new WeakMap<SubmissionStore, number>();
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `submissionsSetup` for the editor's own pages (/admin's first paint, the documents list):
|
|
238
|
+
* the count is raced against ~1.5 s, so a bucket that is slow or down can't stall the editor.
|
|
239
|
+
* On a timeout or an error the answer is the `submissions-unreadable` shape, and for ~30 s
|
|
240
|
+
* after, that is answered at once without asking again. A count that was still running when
|
|
241
|
+
* the wait ended keeps going, and clears the memory if it lands. The inbox's own routes
|
|
242
|
+
* don't use this.
|
|
243
|
+
*/
|
|
244
|
+
export async function submissionsSetupQuick(
|
|
245
|
+
ctx: Pick<InboxContext, 'site' | 'env' | 'store' | 'fetch' | 'log'>,
|
|
246
|
+
timing: { waitMs?: number; retryMs?: number; now?: () => number } = {},
|
|
247
|
+
): Promise<SubmissionsSetup> {
|
|
248
|
+
const store = inboxStore(ctx);
|
|
249
|
+
if ('code' in store) return { unread: null, error: store.error, code: store.code, missing: store.missing };
|
|
250
|
+
const { waitMs = SETUP_WAIT_MS, retryMs = SETUP_RETRY_MS, now = Date.now } = timing;
|
|
251
|
+
const unreadable: SubmissionsSetup = { unread: null, error: "The submissions couldn't be read just now.", code: 'submissions-unreadable', missing: [] };
|
|
252
|
+
const failedAt = setupFailures.get(store);
|
|
253
|
+
if (failedAt !== undefined && now() - failedAt < retryMs) return unreadable;
|
|
254
|
+
|
|
255
|
+
const counting = submissionsSetup({ ...ctx, store });
|
|
256
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
257
|
+
const timedOut = new Promise<'timeout'>((resolve) => {
|
|
258
|
+
timer = setTimeout(() => resolve('timeout'), waitMs);
|
|
259
|
+
});
|
|
260
|
+
const result = await Promise.race([counting, timedOut]);
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
if (result === 'timeout') {
|
|
263
|
+
setupFailures.set(store, now());
|
|
264
|
+
(ctx.log ?? ((line: string) => console.error(line)))(`[caracms] counting the unread submissions took over ${waitMs} ms; going on without it.`);
|
|
265
|
+
void counting.then((late) => {
|
|
266
|
+
if (late.unread !== null) setupFailures.delete(store);
|
|
267
|
+
});
|
|
268
|
+
return unreadable;
|
|
269
|
+
}
|
|
270
|
+
if (result.unread === null) setupFailures.set(store, now());
|
|
271
|
+
else setupFailures.delete(store);
|
|
272
|
+
return result;
|
|
273
|
+
}
|