caracms 0.5.1 → 0.7.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/README.md +126 -16
- package/bin/caracms.mjs +5 -2
- package/bin/media.mjs +308 -30
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +3 -1
- package/src/cloud-middleware.ts +85 -0
- package/src/editor/README.md +2 -1
- package/src/editor/server/media/cloud-bucket.ts +144 -0
- package/src/editor/server/media/cloud-mux.ts +110 -0
- package/src/editor/server/media/config.ts +30 -3
- package/src/editor/server/media/handler.ts +13 -1
- package/src/files-route.ts +2 -1
- package/src/forms/cloud-store.ts +212 -0
- package/src/forms/email.ts +52 -2
- package/src/forms/endpoint.ts +17 -5
- package/src/forms/store.ts +38 -3
- package/src/index.ts +10 -1
- package/src/live/README.md +1 -1
- package/src/live/auth/email.ts +22 -0
- package/src/live/auth/middleware.ts +2 -2
- package/src/live/cloud.ts +151 -18
- package/src/live/github.ts +2 -2
- package/src/live/mode.ts +44 -1
- package/src/live/runtime.ts +15 -2
- package/src/media.ts +50 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The submissions store on CaraCMS Cloud (caracms-cloud M4 Decisions 5–9 and 23), behind
|
|
3
|
+
* store.ts's `SubmissionStore`, so the endpoint's checks and the inbox don't change. The
|
|
4
|
+
* site's server never holds R2 or Resend credentials: it asks the API (live/cloud.ts, the
|
|
5
|
+
* site key), which answers for the site's own folder.
|
|
6
|
+
*
|
|
7
|
+
* - `create`: mints the id (`newSubmissionId`, the package's own shape), builds the
|
|
8
|
+
* `StoredSubmission`, asks the `email` callback to write the email for it, and sends both
|
|
9
|
+
* (`POST /v1/submissions`). On `409 exists` it mints a new id, writes the email again (its
|
|
10
|
+
* link names the id) and tries once more. With files, the answer is **pending** with one
|
|
11
|
+
* presigned PUT per file: each is uploaded with exactly the headers named, then
|
|
12
|
+
* `POST /v1/submissions/confirm`, tried once more on a timeout or an unreachable cloud.
|
|
13
|
+
* An upload that fails, or a confirm answered `409 incomplete`, removes the row
|
|
14
|
+
* (`POST /v1/submissions/delete`); a confirm that never got through is left to the
|
|
15
|
+
* cloud's hourly clean-up. Either way `create` throws: the visitor gets the endpoint's 502.
|
|
16
|
+
* - `get`, `list`, `update` (`readAt` only), `delete` and `getFile` (a 5-minute download
|
|
17
|
+
* URL, then the bytes): one route each.
|
|
18
|
+
* - **The unread count comes back with every answer**; `unreadCount()` gives the last one
|
|
19
|
+
* this store saw, or asks (`GET /v1/submissions/unread`). store.ts makes a store per
|
|
20
|
+
* request, so the count is never older than the request.
|
|
21
|
+
*
|
|
22
|
+
* Refusals are `CloudRefusal`s with the cloud's own sentence (the endpoint logs it, the
|
|
23
|
+
* inbox shows it). Presigned URLs go nowhere but the fetch here: never in a log line or an error.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { cloudCall, CloudRefusal, isCloudRefusal, type CloudConnection } from '../live/cloud.ts';
|
|
27
|
+
import { isSubmissionId } from './rules.ts';
|
|
28
|
+
import { fileKey, isFileKey, newSubmissionId, PAGE_SIZE, type StoredFile, type StoredSubmission, type SubmissionStore } from './store.ts';
|
|
29
|
+
|
|
30
|
+
const STORAGE_TIMEOUT_MS = 20_000;
|
|
31
|
+
|
|
32
|
+
const malformed = (what: string) => new CloudRefusal('unreachable', `CaraCMS Cloud's answer to ${what} isn't one the package knows.`);
|
|
33
|
+
|
|
34
|
+
const count = (v: unknown): number | null => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
|
|
35
|
+
|
|
36
|
+
/** A submission as the cloud answered it: the package's own shape, checked enough to be read by the inbox. */
|
|
37
|
+
function submissionOf(value: unknown, what: string): StoredSubmission {
|
|
38
|
+
const s = value as StoredSubmission | null;
|
|
39
|
+
if (!s || typeof s !== 'object' || !isSubmissionId(s.id) || typeof s.form !== 'string' || typeof s.receivedAt !== 'string' || !s.fields || typeof s.fields !== 'object') {
|
|
40
|
+
throw malformed(what);
|
|
41
|
+
}
|
|
42
|
+
return { ...s, v: 1, readAt: typeof s.readAt === 'string' ? s.readAt : null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function cloudStore(connection: CloudConnection): SubmissionStore {
|
|
46
|
+
const fetchImpl = connection.fetch ?? ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args));
|
|
47
|
+
/** The unread count from the last answer that carried one. */
|
|
48
|
+
let unread: number | null = null;
|
|
49
|
+
const seen = (answer: Record<string, unknown>) => {
|
|
50
|
+
const n = count(answer.unread);
|
|
51
|
+
if (n !== null) unread = n;
|
|
52
|
+
};
|
|
53
|
+
const api = (method: 'GET' | 'POST', path: string, body?: unknown) => cloudCall(connection, method, path, body);
|
|
54
|
+
/** Null for the cloud's `404 not_found`; any other refusal goes up as sent. */
|
|
55
|
+
async function orNull(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown> | null> {
|
|
56
|
+
try {
|
|
57
|
+
return await api(method, path, body);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (isCloudRefusal(error) && error.error === 'not_found') return null;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A presigned URL's request, with its own timeout. */
|
|
65
|
+
async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
|
|
66
|
+
try {
|
|
67
|
+
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(STORAGE_TIMEOUT_MS) });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`submissions: ${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The confirm, tried once more when the cloud didn't answer (Decision 6). */
|
|
74
|
+
async function confirm(id: string): Promise<void> {
|
|
75
|
+
for (let attempt = 1; ; attempt++) {
|
|
76
|
+
try {
|
|
77
|
+
await api('POST', '/v1/submissions/confirm', { id });
|
|
78
|
+
return;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (attempt < 2 && isCloudRefusal(error) && error.error === 'unreachable') continue;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The row goes, best effort: the cloud's hourly clean-up takes it otherwise. */
|
|
87
|
+
const discard = (id: string) => api('POST', '/v1/submissions/delete', { id }).catch(() => {});
|
|
88
|
+
|
|
89
|
+
async function get(id: string): Promise<StoredSubmission | null> {
|
|
90
|
+
if (!isSubmissionId(id)) return null;
|
|
91
|
+
const answer = await orNull('GET', `/v1/submissions/get?${new URLSearchParams({ id })}`);
|
|
92
|
+
if (!answer) return null;
|
|
93
|
+
seen(answer);
|
|
94
|
+
const submission = submissionOf(answer.submission, 'get');
|
|
95
|
+
return submission.id === id ? submission : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
sendsEmail: true,
|
|
100
|
+
async create({ form, receivedAt, fields, files, email }) {
|
|
101
|
+
const hashes = (files ?? []).map((f) => createHash('sha256').update(f.data).digest('hex'));
|
|
102
|
+
for (let attempt = 1; ; attempt++) {
|
|
103
|
+
const id = newSubmissionId(receivedAt);
|
|
104
|
+
const stored: StoredFile[] = (files ?? []).map((f, i) => ({ name: f.name, size: f.data.byteLength, type: f.type, key: fileKey(id, i + 1, f.name) }));
|
|
105
|
+
const record: StoredSubmission = { v: 1, id, form, receivedAt, fields, ...(stored.length ? { files: stored } : {}), readAt: null };
|
|
106
|
+
const written = email?.(record) ?? { emailSkipped: 'No email was written for this submission.' };
|
|
107
|
+
let answer: Record<string, unknown>;
|
|
108
|
+
try {
|
|
109
|
+
answer = await api('POST', '/v1/submissions', {
|
|
110
|
+
id,
|
|
111
|
+
form,
|
|
112
|
+
receivedAt,
|
|
113
|
+
fields,
|
|
114
|
+
...(stored.length ? { files: stored.map((f, i) => ({ name: f.name, type: f.type, size: f.size, key: f.key, sha256: hashes[i] })) } : {}),
|
|
115
|
+
...written,
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// The id was already the site's (Decision 5): a new one, and the email written again for it.
|
|
119
|
+
if (attempt < 2 && isCloudRefusal(error) && error.error === 'exists') continue;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (answer.state === 'complete') return record;
|
|
123
|
+
if (answer.state !== 'pending' || !Array.isArray(answer.uploads)) throw malformed('a new submission');
|
|
124
|
+
try {
|
|
125
|
+
for (const file of stored) {
|
|
126
|
+
const upload = (answer.uploads as Record<string, unknown>[]).find((u) => u && typeof u === 'object' && u.key === file.key);
|
|
127
|
+
const { url, method, headers } = upload ?? {};
|
|
128
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
|
|
129
|
+
throw malformed('a new submission');
|
|
130
|
+
}
|
|
131
|
+
// The headers as the cloud named them (the URL signs each); Content-Length and Host are fetch's own.
|
|
132
|
+
const sent = Object.fromEntries(
|
|
133
|
+
Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
|
|
134
|
+
) as Record<string, string>;
|
|
135
|
+
const n = stored.indexOf(file);
|
|
136
|
+
const res = await storage(`upload ${n + 1}`, url, { method: 'PUT', headers: sent, body: files![n]!.data as BodyInit });
|
|
137
|
+
await res.body?.cancel().catch(() => {});
|
|
138
|
+
// 412: the signed If-None-Match: * found the key already there, so an earlier try put it.
|
|
139
|
+
if (!res.ok && res.status !== 412) throw new Error(`submissions: upload ${n + 1} of ${stored.length}: the storage answered ${res.status}`);
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
await discard(id);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await confirm(id);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// `409 incomplete`: a file isn't there at its size, so the row goes. Unreachable: the cloud's clean-up takes it.
|
|
149
|
+
if (isCloudRefusal(error) && error.error !== 'unreachable') await discard(id);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
return record;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
get,
|
|
156
|
+
async getFile(id, n) {
|
|
157
|
+
if (!isSubmissionId(id) || !Number.isInteger(n) || n < 1) return null;
|
|
158
|
+
const answer = await orNull('POST', '/v1/submissions/file-url', { id, n });
|
|
159
|
+
if (!answer) return null;
|
|
160
|
+
const file = answer.file as StoredFile | undefined;
|
|
161
|
+
// Checked against the id asked for: a key that isn't this submission's is never read.
|
|
162
|
+
if (typeof answer.url !== 'string' || !file || typeof file !== 'object' || !isFileKey(id, file.key)) throw malformed('a file');
|
|
163
|
+
const res = await storage(`download ${n}`, answer.url, { method: 'GET' });
|
|
164
|
+
if (res.status === 404) {
|
|
165
|
+
await res.body?.cancel().catch(() => {});
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
await res.body?.cancel().catch(() => {});
|
|
170
|
+
throw new Error(`submissions: download ${n}: the storage answered ${res.status}`);
|
|
171
|
+
}
|
|
172
|
+
return { file: { name: String(file.name ?? ''), size: Number(file.size) || 0, type: String(file.type ?? ''), key: file.key }, body: new Uint8Array(await res.arrayBuffer()) };
|
|
173
|
+
},
|
|
174
|
+
async list(opts = {}) {
|
|
175
|
+
const limit = Math.max(1, Math.min(500, opts.limit ?? PAGE_SIZE));
|
|
176
|
+
const query = new URLSearchParams({ limit: String(limit) });
|
|
177
|
+
if (opts.before) {
|
|
178
|
+
// The cloud refuses a cursor not of the id's shape; v3's stores treat it as "after everything".
|
|
179
|
+
if (!isSubmissionId(opts.before)) return { items: [], older: null };
|
|
180
|
+
query.set('before', opts.before);
|
|
181
|
+
}
|
|
182
|
+
const answer = await api('GET', `/v1/submissions?${query}`);
|
|
183
|
+
if (!Array.isArray(answer.items)) throw malformed('the list');
|
|
184
|
+
seen(answer);
|
|
185
|
+
const older = answer.older;
|
|
186
|
+
return { items: answer.items.map((s) => submissionOf(s, 'the list')), older: typeof older === 'string' && isSubmissionId(older) ? older : null };
|
|
187
|
+
},
|
|
188
|
+
async update(id, patch) {
|
|
189
|
+
// The cloud records the email's outcome itself; only `readAt` is the inbox's to change.
|
|
190
|
+
if (!('readAt' in patch)) return get(id);
|
|
191
|
+
if (!isSubmissionId(id)) return null;
|
|
192
|
+
const answer = await orNull('POST', '/v1/submissions/update', { id, readAt: patch.readAt ?? null });
|
|
193
|
+
if (!answer) return null;
|
|
194
|
+
seen(answer);
|
|
195
|
+
return submissionOf(answer.submission, 'an update');
|
|
196
|
+
},
|
|
197
|
+
async delete(id) {
|
|
198
|
+
if (!isSubmissionId(id)) return false;
|
|
199
|
+
const answer = await api('POST', '/v1/submissions/delete', { id });
|
|
200
|
+
seen(answer);
|
|
201
|
+
return answer.deleted === true;
|
|
202
|
+
},
|
|
203
|
+
async unreadCount() {
|
|
204
|
+
if (unread !== null) return unread;
|
|
205
|
+
const answer = await api('GET', '/v1/submissions/unread');
|
|
206
|
+
const n = count(answer.unread);
|
|
207
|
+
if (n === null) throw malformed('the unread count');
|
|
208
|
+
unread = n;
|
|
209
|
+
return n;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
package/src/forms/email.ts
CHANGED
|
@@ -15,13 +15,16 @@
|
|
|
15
15
|
* dropped, one that doesn't look like an address skipped. An empty list is `skipped`.
|
|
16
16
|
* - **Only a production server sends.** Elsewhere the email is written to the log and
|
|
17
17
|
* recorded `skipped`, saying so.
|
|
18
|
+
* - **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the site writes the email the same
|
|
19
|
+
* way (`writeNotification`), from "<brand> – <form label>", to at most ten addresses,
|
|
20
|
+
* and the cloud store hands it over with the submission; the cloud sends it.
|
|
18
21
|
*/
|
|
19
22
|
import { readFile } from 'node:fs/promises';
|
|
20
23
|
import { join } from 'node:path';
|
|
21
24
|
import { SITE_SETTINGS_FILE } from '../reserved.ts';
|
|
22
25
|
import { sendEmail, sendsEmail } from '../email.ts';
|
|
23
26
|
import { formatBytes, looksLikeEmail, shownFileName, submissionAddress, type ResolvedForm } from './rules.ts';
|
|
24
|
-
import type { EmailOutcome, StoredSubmission } from './store.ts';
|
|
27
|
+
import type { EmailOutcome, StoredSubmission, WrittenEmail } from './store.ts';
|
|
25
28
|
|
|
26
29
|
type Env = Record<string, string | undefined>;
|
|
27
30
|
|
|
@@ -116,6 +119,53 @@ export function buildNotification(input: { submission: StoredSubmission; form: R
|
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
/** The most addresses one form email goes to on CaraCMS Cloud (caracms-cloud M4 Decision 4). */
|
|
123
|
+
export const MAX_RECIPIENTS = 10;
|
|
124
|
+
|
|
125
|
+
/** The sender's display name on CaraCMS Cloud (M4 Decision 11): `<brand name> – <form label>`, "Urban Return – Contact". The cloud cleans it further. */
|
|
126
|
+
export function fromName(brandName: string, formLabel: string): string {
|
|
127
|
+
return [brandName.trim(), formLabel.trim()].filter(Boolean).join(' – ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The sentence for a form with no one set to get its emails. */
|
|
131
|
+
const noRecipients = (label: string) => `No one is set to get the ${label} form's emails (Site Settings → Form emails).`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The email a cloud site's server hands the cloud with a submission (M4 Decisions 11 and 24):
|
|
135
|
+
* written as `sendNotification` writes it, the recipients capped at the first ten (the rest
|
|
136
|
+
* logged), or the reason there is none. `settings` is the Site Settings read, or the error
|
|
137
|
+
* reading them threw. Never throws.
|
|
138
|
+
*/
|
|
139
|
+
export function writeNotification(input: {
|
|
140
|
+
submission: StoredSubmission;
|
|
141
|
+
form: ResolvedForm | null;
|
|
142
|
+
settings: FormSettings | { error: unknown };
|
|
143
|
+
siteUrl: string | null;
|
|
144
|
+
timeZone: string;
|
|
145
|
+
log?: (line: string) => void;
|
|
146
|
+
}): WrittenEmail {
|
|
147
|
+
const log = input.log ?? ((line: string) => console.log(line));
|
|
148
|
+
if ('error' in input.settings) {
|
|
149
|
+
const { error } = input.settings;
|
|
150
|
+
return { emailFailed: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
|
|
151
|
+
}
|
|
152
|
+
const settings = input.settings;
|
|
153
|
+
const label = input.form?.label ?? input.submission.form;
|
|
154
|
+
const all = sendableAddresses(settings.recipients[input.submission.form] ?? []);
|
|
155
|
+
if (all.length === 0) return { emailSkipped: noRecipients(label) };
|
|
156
|
+
const to = all.slice(0, MAX_RECIPIENTS);
|
|
157
|
+
if (all.length > to.length) {
|
|
158
|
+
const left = all.length - to.length;
|
|
159
|
+
log(`[caracms] forms: the ${label} form has ${all.length} recipients; its email goes to the first ${MAX_RECIPIENTS}, and ${left} ${left === 1 ? 'was' : 'were'} left off (Site Settings → Form emails).`);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const built = buildNotification({ submission: input.submission, form: input.form, brandName: settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
|
|
163
|
+
return { email: { to, subject: built.subject, text: built.text, ...(built.replyTo ? { replyTo: built.replyTo } : {}), fromName: fromName(settings.brandName, label) } };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { emailFailed: `Could not write the email: ${error instanceof Error ? error.message : String(error)}` };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
119
169
|
/** Send a submission's email and say what happened (the outcome recorded on it). Never throws. */
|
|
120
170
|
export async function sendNotification(input: {
|
|
121
171
|
submission: StoredSubmission;
|
|
@@ -133,7 +183,7 @@ export async function sendNotification(input: {
|
|
|
133
183
|
const log = input.log ?? ((line: string) => console.log(line));
|
|
134
184
|
const to = sendableAddresses(input.settings.recipients[input.submission.form] ?? []);
|
|
135
185
|
const label = input.form?.label ?? input.submission.form;
|
|
136
|
-
if (to.length === 0) return { status: 'skipped', at, to, error:
|
|
186
|
+
if (to.length === 0) return { status: 'skipped', at, to, error: noRecipients(label) };
|
|
137
187
|
let built: ReturnType<typeof buildNotification>;
|
|
138
188
|
try {
|
|
139
189
|
built = buildNotification({ submission: input.submission, form: input.form, brandName: input.settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
|
package/src/forms/endpoint.ts
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
* 403; Cloudflare unreachable fails open, logged. Unset: accepted, with a warning.
|
|
30
30
|
* 8. **Store** (store.ts), then **email** (email.ts), then **record** the email's outcome
|
|
31
31
|
* on the submission, then reply. A storage failure is a 502; an email failure never
|
|
32
|
-
* fails the visitor.
|
|
32
|
+
* fails the visitor. **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the store
|
|
33
|
+
* takes the written email with the submission and the cloud sends and records it, so
|
|
34
|
+
* nothing is sent or recorded here; a cloud refusal is the same 502, its sentence logged.
|
|
33
35
|
*
|
|
34
36
|
* Replies: `Accept: application/json` gets `{ ok: true }` or `{ error }`; a no-JS post
|
|
35
37
|
* gets a 303 to `<return>?sent=1`, or the error as plain text. Nothing about the visitor
|
|
@@ -40,7 +42,7 @@ import { publicOrigin } from '../live/auth/index.ts';
|
|
|
40
42
|
import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
|
|
41
43
|
import { readCapped } from '../editor/server/media/upload.ts';
|
|
42
44
|
import { isProductionBuild } from '../media.ts';
|
|
43
|
-
import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
|
|
45
|
+
import { readFormSettings, sendNotification, writeNotification, type FormSettings } from './email.ts';
|
|
44
46
|
import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
|
|
45
47
|
import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
|
|
46
48
|
import { cleanFileName, fileExtension, readSubmissionsConfig, submissionStore, type NewFile, type SubmissionStore } from './store.ts';
|
|
@@ -313,21 +315,31 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
|
|
|
313
315
|
}
|
|
314
316
|
store = chosen;
|
|
315
317
|
}
|
|
318
|
+
const siteUrl = publicOrigin(ctx.astroSite, env);
|
|
319
|
+
const readSettings = ctx.readSettings ?? (() => readFormSettings(ctx.site.root));
|
|
320
|
+
// On CaraCMS Cloud (caracms-cloud M4 Decision 24) the email goes with the submission: written here, sent by the cloud.
|
|
321
|
+
const settingsRead = store.sendsEmail ? await readSettings().catch((error: unknown) => ({ error })) : null;
|
|
316
322
|
let submission;
|
|
317
323
|
try {
|
|
318
324
|
const files: NewFile[] = await Promise.all(vetted.files.map(async (f) => ({ name: f.name, type: f.type, data: new Uint8Array(await f.blob.arrayBuffer()) })));
|
|
319
|
-
submission = await store.create({
|
|
325
|
+
submission = await store.create({
|
|
326
|
+
form: form.id,
|
|
327
|
+
receivedAt: now().toISOString(),
|
|
328
|
+
fields: vetted.fields,
|
|
329
|
+
...(files.length ? { files } : {}),
|
|
330
|
+
...(settingsRead ? { email: (stored) => writeNotification({ submission: stored, form, settings: settingsRead, siteUrl, timeZone: ctx.site.timeZone, log }) } : {}),
|
|
331
|
+
});
|
|
320
332
|
} catch (error) {
|
|
321
333
|
log(`[caracms] forms: couldn't store a ${form.id} submission: ${error instanceof Error ? error.message : String(error)}`);
|
|
322
334
|
return reply(502, { error: STORE_FAILED_MESSAGE });
|
|
323
335
|
}
|
|
324
336
|
await limiter.hit(ip);
|
|
337
|
+
if (store.sendsEmail) return reply(200, { ok: true });
|
|
325
338
|
|
|
326
339
|
// EMAIL, then RECORD its outcome. Neither changes the visitor's reply.
|
|
327
340
|
let email;
|
|
328
341
|
try {
|
|
329
|
-
const settings = await
|
|
330
|
-
const siteUrl = publicOrigin(ctx.astroSite, env);
|
|
342
|
+
const settings = await readSettings();
|
|
331
343
|
email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
|
|
332
344
|
} catch (error) {
|
|
333
345
|
email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
|
package/src/forms/store.ts
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* gets these. **The production server never falls back to disk**: with any of them
|
|
10
10
|
* unset, the store is `unset` (the endpoint's 502, the inbox's sentence).
|
|
11
11
|
* - **Everywhere else**: `.caracms/submissions/` under the site's root (gitignored),
|
|
12
|
-
* **even with the bucket variables set**, so a laptop never lands a test enquiry in
|
|
12
|
+
* **even with the bucket variables or the cloud key set**, so a laptop never lands a test enquiry in
|
|
13
13
|
* the client's inbox, nor copies their visitors' data down.
|
|
14
|
+
* - **CaraCMS Cloud** (caracms-cloud M4 Decision 23): a production server with
|
|
15
|
+
* `CARACMS_CLOUD_KEY` stores through the cloud's API (cloud-store.ts), and the cloud sends
|
|
16
|
+
* the email. The key with any of the site's own forms and email variables is `unset`
|
|
17
|
+
* with Decision 22's sentence.
|
|
14
18
|
* - **`CARACMS_SUBMISSIONS=memory`**: in memory, one per process, for tests. Refused in
|
|
15
19
|
* production, as `CARACMS_MEDIA=memory` is.
|
|
16
20
|
*
|
|
@@ -31,6 +35,8 @@ import { createHash, randomInt } from 'node:crypto';
|
|
|
31
35
|
import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
32
36
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
33
37
|
import { AwsClient } from 'aws4fetch';
|
|
38
|
+
import { cloudForms } from '../live/mode.ts';
|
|
39
|
+
import { cloudStore } from './cloud-store.ts';
|
|
34
40
|
import { isSubmissionId, shownFileName } from './rules.ts';
|
|
35
41
|
|
|
36
42
|
type Env = Record<string, string | undefined>;
|
|
@@ -77,8 +83,24 @@ export interface NewFile {
|
|
|
77
83
|
data: Uint8Array;
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What a cloud site's server sends with a submission (caracms-cloud M4 Decision 11): the
|
|
88
|
+
* email, written, or the reason there is none. The cloud sends it once the row is stored.
|
|
89
|
+
*/
|
|
90
|
+
export type WrittenEmail =
|
|
91
|
+
| { email: { to: string[]; subject: string; text: string; replyTo?: string; fromName: string } }
|
|
92
|
+
| { emailSkipped: string }
|
|
93
|
+
| { emailFailed: string };
|
|
94
|
+
|
|
80
95
|
export interface SubmissionStore {
|
|
81
|
-
|
|
96
|
+
/**
|
|
97
|
+
* `email` writes the email for the submission as stored (its id, files and time), and only
|
|
98
|
+
* the cloud store calls it (M4 Decision 23): it may call it twice, when the cloud already
|
|
99
|
+
* had the first id. The other stores ignore it; the endpoint sends their email itself.
|
|
100
|
+
*/
|
|
101
|
+
create(input: { form: string; receivedAt: string; fields: Record<string, string>; files?: NewFile[]; email?: (submission: StoredSubmission) => WrittenEmail }): Promise<StoredSubmission>;
|
|
102
|
+
/** True for the cloud store: the email goes with `create`, and the cloud sends it and records the outcome. */
|
|
103
|
+
readonly sendsEmail?: boolean;
|
|
82
104
|
get(id: string): Promise<StoredSubmission | null>;
|
|
83
105
|
getFile(id: string, n: number): Promise<{ file: StoredFile; body: Uint8Array } | null>;
|
|
84
106
|
/** Newest first, `limit` (default 50) after `before`; `older` is the cursor for the next page, or null. */
|
|
@@ -426,6 +448,8 @@ export type SubmissionsConfig =
|
|
|
426
448
|
| { kind: 'memory' }
|
|
427
449
|
| { kind: 'folder'; dir: string }
|
|
428
450
|
| { kind: 'bucket'; bucket: SubmissionsBucket }
|
|
451
|
+
/** CaraCMS Cloud (caracms-cloud M4 Decision 23): a production server with `CARACMS_CLOUD_KEY`. */
|
|
452
|
+
| { kind: 'cloud'; connection: { key: string; url: string } }
|
|
429
453
|
/** The production server without its bucket: `message` names what's missing (the endpoint's log, the inbox's sentence). */
|
|
430
454
|
| { kind: 'unset'; message: string; missing: string[] };
|
|
431
455
|
|
|
@@ -440,10 +464,19 @@ export function readSubmissionsConfig(root: string, env: Env = process.env, prod
|
|
|
440
464
|
const mode = get('CARACMS_SUBMISSIONS');
|
|
441
465
|
if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
442
466
|
if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
|
|
467
|
+
// The key with the site's own variables is a mistake everywhere (M4 Decision 22), as the media's is.
|
|
468
|
+
const cloud = cloudForms(env);
|
|
469
|
+
if (cloud?.problems.length) return { kind: 'unset', message: cloud.problems.join(' '), missing: [] };
|
|
470
|
+
// Outside production: the folder, even with the cloud key (M4's Core framing).
|
|
443
471
|
if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
|
|
472
|
+
if (cloud) return { kind: 'cloud', connection: cloud.connection };
|
|
444
473
|
const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
|
|
445
474
|
if (missing.length) {
|
|
446
|
-
return {
|
|
475
|
+
return {
|
|
476
|
+
kind: 'unset',
|
|
477
|
+
message: `Form submissions aren't set up on this server: set CARACMS_CLOUD_KEY to the site's CaraCMS Cloud key, or set ${list(missing)} to the site's submissions bucket.`,
|
|
478
|
+
missing,
|
|
479
|
+
};
|
|
447
480
|
}
|
|
448
481
|
return {
|
|
449
482
|
kind: 'bucket',
|
|
@@ -474,6 +507,8 @@ export function resetSubmissions(): void {
|
|
|
474
507
|
/** The store for this config, one per process per backend (so the unread count is kept), or the `unset` config itself. */
|
|
475
508
|
export function submissionStore(config: SubmissionsConfig, fetchImpl?: typeof fetch): SubmissionStore | Extract<SubmissionsConfig, { kind: 'unset' }> {
|
|
476
509
|
if (config.kind === 'unset') return config;
|
|
510
|
+
// One per call (a request): the cloud keeps the unread count, and the store holds only the count its own last answer gave.
|
|
511
|
+
if (config.kind === 'cloud') return cloudStore({ ...config.connection, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
477
512
|
const s = shared();
|
|
478
513
|
const id = config.kind === 'memory' ? 'memory' : config.kind === 'folder' ? `folder:${config.dir}` : `bucket:${config.bucket.endpoint}:${config.bucket.bucket}:${config.bucket.accessKeyId}`;
|
|
479
514
|
let store = s.stores.get(id);
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
* (Decision 5) and has Vite pre-bundle the editor's browser deps. The public pages
|
|
47
47
|
* have no script, no React and no editor markers in either.
|
|
48
48
|
*/
|
|
49
|
+
import { cloudSettingsForBuild } from './cloud-middleware.ts';
|
|
49
50
|
import type { AstroIntegration } from 'astro';
|
|
50
51
|
import { fontProviders } from 'astro/config';
|
|
51
52
|
import { execFileSync } from 'node:child_process';
|
|
@@ -198,6 +199,8 @@ export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
|
|
|
198
199
|
|
|
199
200
|
/** M4 Phase 2: the unconfigured server's 503, registered before Phase 3's guard. */
|
|
200
201
|
export const EDITOR_MIDDLEWARE = 'editor/middleware.ts';
|
|
202
|
+
/** caracms-cloud M3 Decision 20: a cloud site's settings, fetched by the first request (cloud-middleware.ts). */
|
|
203
|
+
export const CLOUD_MIDDLEWARE = 'cloud-middleware.ts';
|
|
201
204
|
|
|
202
205
|
/**
|
|
203
206
|
* Content files out of Vite's watch (Decision 5): a write never reloads `/admin`
|
|
@@ -410,6 +413,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
410
413
|
if (address.url) injectRoute({ pattern: SITEMAP_ROUTE.pattern, entrypoint: new URL(`./${SITEMAP_ROUTE.entrypoint}`, import.meta.url), prerender: true });
|
|
411
414
|
// M4 Phase 3: the sign-in pages (live/auth/routes.ts).
|
|
412
415
|
for (const route of LOGIN_ROUTES) injectRoute({ pattern: route.pattern, entrypoint: new URL(`./${route.entrypoint}`, import.meta.url), prerender: false });
|
|
416
|
+
// caracms-cloud M3 Decision 20: a cloud site's settings, awaited before anything renders. First of all.
|
|
417
|
+
addMiddleware({ order: 'pre', entrypoint: new URL(`./${CLOUD_MIDDLEWARE}`, import.meta.url) });
|
|
413
418
|
// M4 Phase 2: an unconfigured production server's 503 on /admin and /_caracms/*. First, so it runs before the guard.
|
|
414
419
|
addMiddleware({ order: 'pre', entrypoint: new URL(`./${EDITOR_MIDDLEWARE}`, import.meta.url) });
|
|
415
420
|
// M4 Phase 3: the sign-in guard on /admin and /_caracms/ (live mode only). Must stay after the 503 middleware.
|
|
@@ -510,7 +515,11 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
510
515
|
server.httpServer?.once('close', stop);
|
|
511
516
|
},
|
|
512
517
|
// Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
|
|
513
|
-
|
|
518
|
+
// caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
|
|
519
|
+
'astro:build:start': async () => {
|
|
520
|
+
shareBuildReads(true);
|
|
521
|
+
await cloudSettingsForBuild();
|
|
522
|
+
},
|
|
514
523
|
'astro:build:done': () => {
|
|
515
524
|
shareBuildReads(false);
|
|
516
525
|
if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
|
package/src/live/README.md
CHANGED
|
@@ -19,7 +19,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
19
19
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
20
20
|
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
21
21
|
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts). A third credential, `cloud`: the token from CaraCMS Cloud, refreshed the same way; its refusals are an access problem of kind `cloud` |
|
|
22
|
-
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `cloudSettings()` (the repo, at start), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
22
|
+
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `fetchCloudSettings()` (the repo and the media; M3), `loadCloudSettings()` / `heldCloudSettings()` (one fetch per process, shared by the media and the runtime), `cloudSettings()` (the repo, at start), `cloudCall()` (one call, for the cloud bucket too), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
23
23
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
24
24
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
25
25
|
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
|
package/src/live/auth/email.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* server log and nothing is sent, so no test from a laptop reaches anyone's inbox.
|
|
8
8
|
* The email names the site by its brand name. The link carries only the token.
|
|
9
9
|
*
|
|
10
|
+
* **On CaraCMS Cloud** (caracms-cloud M4 Decision 25), a production server with
|
|
11
|
+
* `CARACMS_CLOUD_KEY` asks the cloud instead (`POST /v1/email/sign-in`), which writes the
|
|
12
|
+
* email from its own copy of this template and sends it from `accounts.caracms.com`. The
|
|
13
|
+
* key beside `RESEND_API_KEY` or `FORMS_FROM` (Decision 22) sends nothing and logs why.
|
|
14
|
+
* Outside production the link is logged, key or not.
|
|
15
|
+
*
|
|
10
16
|
* The HTML part is one table layout with inline styles (mail clients strip `<style>`),
|
|
11
17
|
* the system font stack, no images; the plain-text part says the same.
|
|
12
18
|
*/
|
|
13
19
|
import { sendEmail, sendsEmail } from '../../email.ts';
|
|
20
|
+
import { cloudCall, isCloudRefusal } from '../cloud.ts';
|
|
21
|
+
import { cloudForms } from '../mode.ts';
|
|
14
22
|
|
|
15
23
|
export { RESEND_URL } from '../../email.ts';
|
|
16
24
|
const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
@@ -76,6 +84,20 @@ export async function deliverLink(input: {
|
|
|
76
84
|
log(`sign-in link for ${to} (not emailed outside production): ${link}`);
|
|
77
85
|
return 'logged';
|
|
78
86
|
}
|
|
87
|
+
const cloud = cloudForms(env);
|
|
88
|
+
if (cloud) {
|
|
89
|
+
if (cloud.problems.length) {
|
|
90
|
+
log(`could not email a sign-in link: ${cloud.problems.join(' ')}`);
|
|
91
|
+
return 'failed';
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await cloudCall({ ...cloud.connection, ...(input.fetch ? { fetch: input.fetch } : {}) }, 'POST', '/v1/email/sign-in', { to, link, siteName });
|
|
95
|
+
return 'sent';
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log(`could not email a sign-in link: CaraCMS Cloud${isCloudRefusal(error) ? ` (${error.error}): ${error.message}` : `: ${error instanceof Error ? error.message : String(error)}`}`);
|
|
98
|
+
return 'failed';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
79
101
|
const { subject, text, html } = buildLinkEmail({ siteName, link, email: to });
|
|
80
102
|
const result = await sendEmail({ to: [to], subject, text, html }, { env, fetch: input.fetch });
|
|
81
103
|
if (result.status === 'sent') return 'sent';
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import type { MiddlewareHandler } from 'astro';
|
|
26
26
|
import { site } from 'virtual:caracms/config';
|
|
27
27
|
import { editorMode } from '../../editor/server/mode.ts';
|
|
28
|
-
import {
|
|
28
|
+
import { isCloudRefusal, cloudUnavailable } from '../cloud.ts';
|
|
29
29
|
import { setPerson } from '../locals.ts';
|
|
30
30
|
import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
|
|
31
31
|
import { authFor } from './index.ts';
|
|
@@ -42,7 +42,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
|
42
42
|
try {
|
|
43
43
|
auth = await authFor(site, mode.settings, context.site);
|
|
44
44
|
} catch (error) {
|
|
45
|
-
if (error
|
|
45
|
+
if (isCloudRefusal(error)) {
|
|
46
46
|
console.error(`[caracms] sign-in: the live runtime could not start: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
47
47
|
return cloudUnavailable(error, context.url.pathname.startsWith('/_caracms/'));
|
|
48
48
|
}
|