caracms 0.6.0 → 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 +66 -8
- package/package.json +1 -1
- package/src/cloud-middleware.ts +11 -2
- package/src/editor/server/media/cloud-mux.ts +110 -0
- package/src/editor/server/media/config.ts +20 -3
- package/src/editor/server/media/handler.ts +5 -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/live/auth/email.ts +22 -0
- package/src/live/mode.ts +36 -0
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ These go on the site's server. `caracms init` prints the same list.
|
|
|
129
129
|
|
|
130
130
|
| Name | Needed | What it's for |
|
|
131
131
|
|---|---|---|
|
|
132
|
-
| `CARACMS_CLOUD_KEY` | instead of the GitHub and
|
|
132
|
+
| `CARACMS_CLOUD_KEY` | instead of the GitHub, image, form and email variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App, `GITHUB_REPO`, the image variables, and the form and email ones: see [CaraCMS Cloud](#caracms-cloud). |
|
|
133
133
|
| `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
|
|
134
134
|
| `CARACMS_GITHUB_TOKEN` | required, or the App, or the cloud key | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
|
|
135
135
|
| `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
|
|
@@ -139,8 +139,8 @@ These go on the site's server. `caracms init` prints the same list.
|
|
|
139
139
|
| `CARACMS_SECRET` | required | Signs the editors' sessions. A long random string. |
|
|
140
140
|
| `CARACMS_CLIENT_IP_HEADER` | required off Railway, Fly and Render | Where the visitor's IP comes from: `x-real-ip` for nginx with `proxy_set_header X-Real-IP $remote_addr`, `x-forwarded-for` for Caddy, or `socket` if nothing sits in front of this server. `caracms start` won't start without it. |
|
|
141
141
|
| `CARACMS_ALLOW` | for email sign-in | Who may sign in, comma-separated: addresses (`aoife@fernhill.ie`) or whole domains (`@fernhill.ie`). The client adds their own people in Site Settings. |
|
|
142
|
-
| `RESEND_API_KEY` | for email sign-in | Sends the sign-in links (and form notifications) through Resend. |
|
|
143
|
-
| `FORMS_FROM` | for email sign-in | The address those emails come from, on a domain verified in Resend. |
|
|
142
|
+
| `RESEND_API_KEY` | for email sign-in, but not with the cloud key | Sends the sign-in links (and form notifications) through Resend. |
|
|
143
|
+
| `FORMS_FROM` | for email sign-in, but not with the cloud key | The address those emails come from, on a domain verified in Resend. |
|
|
144
144
|
| `CARACMS_BACKUP_USER` | for the backup sign-in | The backup sign-in, for when email sign-in links can't arrive. Both or neither. |
|
|
145
145
|
| `CARACMS_BACKUP_PASSWORD` | for the backup sign-in | The backup sign-in's password. |
|
|
146
146
|
| `CARACMS_RUNNING_COMMIT` | optional | The commit this server runs, where the host doesn't say (Railway and Render do). |
|
|
@@ -155,9 +155,9 @@ A site that uses images, video or forms needs these as well. The image variables
|
|
|
155
155
|
| Feature | Variables |
|
|
156
156
|
|---|---|
|
|
157
157
|
| Images (the media bucket and imgproxy) | `CARACMS_MEDIA_URL` (imgproxy's address), `IMGPROXY_KEY`, `IMGPROXY_SALT`, `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
|
|
158
|
-
| Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` |
|
|
159
|
-
| Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY` |
|
|
160
|
-
| Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above) |
|
|
158
|
+
| Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
|
|
159
|
+
| Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
|
|
160
|
+
| Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above); on CaraCMS Cloud, none of them |
|
|
161
161
|
| Spam checks on forms | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` |
|
|
162
162
|
| A password on the whole site before launch | `CARACMS_PRELAUNCH` |
|
|
163
163
|
| Try again through Railway's API | `CARACMS_RAILWAY_TOKEN` (Railway sets the rest) |
|
|
@@ -331,9 +331,11 @@ repo, and only that repo, about once an hour.
|
|
|
331
331
|
The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
|
|
332
332
|
and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
|
|
333
333
|
`GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well. It
|
|
334
|
-
also replaces the image variables ([Images](#images-on-caracms-cloud), below)
|
|
334
|
+
also replaces the image variables ([Images](#images-on-caracms-cloud), below), the
|
|
335
|
+
video ones ([Video](#video-on-caracms-cloud)), and the form and email ones
|
|
336
|
+
([Forms and email](#forms-and-email-on-caracms-cloud)).
|
|
335
337
|
Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
|
|
336
|
-
`REDIS_URL`, `CARACMS_SECRET`, the sign-in
|
|
338
|
+
`REDIS_URL`, `CARACMS_SECRET`, `CARACMS_ALLOW`, the backup sign-in, and Turnstile.
|
|
337
339
|
There's no webhook: a check once a minute keeps the editor's Publish status up to date.
|
|
338
340
|
|
|
339
341
|
If the cloud says no when the server starts (the App isn't installed on the repo, the
|
|
@@ -368,6 +370,38 @@ builds. A running site keeps serving whatever the cloud does.
|
|
|
368
370
|
`caracms media import <files…>` works with the key too (from the environment or the
|
|
369
371
|
site's `.env`), with the same rules and output, and its uploads count like any other.
|
|
370
372
|
|
|
373
|
+
#### Forms and email on CaraCMS Cloud
|
|
374
|
+
|
|
375
|
+
With the key, the site needs no submissions bucket and no Resend account. Set none of
|
|
376
|
+
`SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`,
|
|
377
|
+
`RESEND_API_KEY` or `FORMS_FROM`: setting any of them beside the key is refused, at build
|
|
378
|
+
and when the server starts, with
|
|
379
|
+
|
|
380
|
+
> Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove RESEND_API_KEY and FORMS_FROM.
|
|
381
|
+
|
|
382
|
+
The site's server still makes every check on a submission (the fields, the honeypot, the
|
|
383
|
+
rate limit, Turnstile) and writes its email, as before. The cloud stores the submission
|
|
384
|
+
and its attachments, and sends the email once it's stored. The editor's inbox reads them
|
|
385
|
+
from the cloud, unchanged.
|
|
386
|
+
|
|
387
|
+
- **Form emails** come from `forms@notify.caracms.com`, named "<brand name> – <form label>"
|
|
388
|
+
(for example "Urban Return – Contact"), with Reply-To the visitor. They go to the form's
|
|
389
|
+
recipients in Site Settings, at most the first ten (the server log says when some were
|
|
390
|
+
left off).
|
|
391
|
+
- **Each site sends at most 50 form emails a day** (UTC) unless CaraCMS raises it. Past
|
|
392
|
+
that, a submission is still stored, and the inbox shows it as "Not emailed: this site
|
|
393
|
+
reached its daily email limit in CaraCMS Cloud (50). Ask CaraCMS to raise it." It isn't
|
|
394
|
+
sent later.
|
|
395
|
+
- **Sign-in links** come from `no-reply@accounts.caracms.com`, in the same words as before.
|
|
396
|
+
|
|
397
|
+
**On a laptop**, or anywhere that isn't a production server (`NODE_ENV=production`),
|
|
398
|
+
nothing goes to the cloud, even with the key: submissions are kept in
|
|
399
|
+
`.caracms/submissions/` and both emails are written to the server log, so a test enquiry
|
|
400
|
+
never reaches a client's inbox.
|
|
401
|
+
|
|
402
|
+
A site on an older caracms keeps its own bucket and Resend until it updates and removes
|
|
403
|
+
them.
|
|
404
|
+
|
|
371
405
|
#### Moving a site's images in
|
|
372
406
|
|
|
373
407
|
`caracms media push` copies everything under `uploads/` in the site's own bucket into its
|
|
@@ -385,6 +419,30 @@ uploader, and never changes the source bucket. It prints `{ copied, skipped, fai
|
|
|
385
419
|
A site bigger than its storage limit stops at the limit with the cloud's sentence: ask
|
|
386
420
|
CaraCMS to raise it, then run it again.
|
|
387
421
|
|
|
422
|
+
#### Video on CaraCMS Cloud
|
|
423
|
+
|
|
424
|
+
With the key, the site's clips are uploaded to CaraCMS Cloud's Mux and listed from the
|
|
425
|
+
site's own clips there; the editor works exactly as it does with a Mux account of your
|
|
426
|
+
own. Set neither `MUX_TOKEN_ID` nor `MUX_TOKEN_SECRET`: setting either beside the key is
|
|
427
|
+
refused with
|
|
428
|
+
|
|
429
|
+
> Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove MUX_TOKEN_ID.
|
|
430
|
+
|
|
431
|
+
Clips play on the site straight from Mux (`stream.mux.com`, `image.mux.com`), as before:
|
|
432
|
+
the public pages never call the cloud. The minutes a site stores count towards its video
|
|
433
|
+
limit (60 minutes unless CaraCMS has set another), and once it's reached, a new upload is
|
|
434
|
+
refused with the cloud's sentence: "This site has reached its video limit in CaraCMS
|
|
435
|
+
Cloud (60 minutes). Ask CaraCMS to raise it." A clip counts once Mux has finished with
|
|
436
|
+
it, so one upload can take a site past its limit; the next is refused.
|
|
437
|
+
|
|
438
|
+
**Moving a site's video in** means uploading its clips again in the editor and placing
|
|
439
|
+
them where the old ones were. There's no copy command. Until a clip is replaced, the old
|
|
440
|
+
one keeps playing on the site from the site's old Mux account: the page only needs its
|
|
441
|
+
playback ID. In the editor it shows its poster and "A clip" (it isn't in the cloud's
|
|
442
|
+
library), and saving other changes on the page leaves it as it is. Remove `MUX_TOKEN_ID`
|
|
443
|
+
and `MUX_TOKEN_SECRET` when you set the key; keep the old Mux account until every clip
|
|
444
|
+
has been replaced.
|
|
445
|
+
|
|
388
446
|
#### Working locally
|
|
389
447
|
|
|
390
448
|
**Anyone working on a real site, wherever they are, uses that site's CaraCMS Cloud**: your
|
package/package.json
CHANGED
package/src/cloud-middleware.ts
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
* `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
|
|
16
16
|
* a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
|
|
17
17
|
* package's own. The build ignores `github.repo`: a site with no repo linked still builds.
|
|
18
|
+
* The key beside the site's own forms and email variables fails it too (caracms-cloud M4
|
|
19
|
+
* Decision 22), whether or not the media is the cloud's.
|
|
18
20
|
*/
|
|
19
21
|
import type { MiddlewareHandler } from 'astro';
|
|
20
22
|
import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
|
|
23
|
+
import { cloudFormsConflict } from './live/mode.ts';
|
|
21
24
|
import { cloudMedia } from './media.ts';
|
|
22
25
|
|
|
23
26
|
type Env = Record<string, string | undefined>;
|
|
@@ -62,9 +65,15 @@ export const onRequest: MiddlewareHandler = async (_context, next) => {
|
|
|
62
65
|
|
|
63
66
|
/** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
|
|
64
67
|
export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
|
|
68
|
+
// caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
|
|
69
|
+
const conflict = cloudFormsConflict(env);
|
|
70
|
+
const forms = conflict ? [conflict] : [];
|
|
65
71
|
const cloud = cloudMedia(env);
|
|
66
|
-
if (!cloud)
|
|
67
|
-
|
|
72
|
+
if (!cloud) {
|
|
73
|
+
if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
|
|
68
77
|
let settings;
|
|
69
78
|
try {
|
|
70
79
|
settings = await loadCloudSettings(cloud.connection);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mux on CaraCMS Cloud (caracms-cloud M4.5 Decision 16), behind mux.ts's seam, so the clip
|
|
3
|
+
* routes, where-used (`withUses`) and the editor don't change. The site's server holds no
|
|
4
|
+
* Mux token: it asks the API (live/cloud.ts, the site key), which asks the cloud's own
|
|
5
|
+
* Mux environment and answers from the site's own clips only. One route per method:
|
|
6
|
+
*
|
|
7
|
+
* - `createUpload`: `POST /v1/video/upload` `{ origin, title }` → `{ id, url }`.
|
|
8
|
+
* - `upload`: `GET /v1/video/upload?id=` → `VideoUploadStatus`.
|
|
9
|
+
* - `clip`: `GET /v1/video/clip?id=` → `Clip`; `clipOfPlayback`: `GET /v1/video/playback?id=`.
|
|
10
|
+
* - `clips`: `GET /v1/video/clips` → `{ clips }`, newest first (the cloud keeps the order).
|
|
11
|
+
*
|
|
12
|
+
* **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
|
|
13
|
+
* them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
|
|
14
|
+
* with the cloud's sentence as sent; any other refusal (`video_unavailable`, the key's own
|
|
15
|
+
* refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
|
|
16
|
+
* an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
|
|
17
|
+
* logged with the refusal's code. The key is never logged nor put in an error.
|
|
18
|
+
*/
|
|
19
|
+
import type { VideoUploadStatus } from '../../types.ts';
|
|
20
|
+
import { cloudCall, isCloudRefusal, type CloudConnection } from '../../../live/cloud.ts';
|
|
21
|
+
import { MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
|
|
22
|
+
|
|
23
|
+
export const CLOUD_VIDEO_UNREACHABLE = "CaraCMS Cloud couldn't be reached just now. Try again in a minute.";
|
|
24
|
+
export const CLOUD_VIDEO_UNREADABLE = 'CaraCMS Cloud sent an unreadable reply.';
|
|
25
|
+
|
|
26
|
+
const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
|
27
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
28
|
+
const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
|
|
29
|
+
|
|
30
|
+
export function cloudMux(
|
|
31
|
+
connection: Pick<CloudConnection, 'key' | 'url'>,
|
|
32
|
+
options: { fetch?: typeof fetch; log?: (line: string) => void } = {},
|
|
33
|
+
): MuxClient {
|
|
34
|
+
const conn: CloudConnection = { ...connection, fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)) };
|
|
35
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
36
|
+
|
|
37
|
+
async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
38
|
+
const where = `${method} ${path.split('?')[0]}`;
|
|
39
|
+
try {
|
|
40
|
+
return await cloudCall(conn, method, path, body);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!isCloudRefusal(error)) throw error;
|
|
43
|
+
if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
|
|
44
|
+
if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
|
|
45
|
+
log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
46
|
+
throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const unreadable = (where: string): never => {
|
|
51
|
+
log(`[caracms] video ${where}: CaraCMS Cloud sent an unreadable reply`);
|
|
52
|
+
throw new MuxRouteError(502, CLOUD_VIDEO_UNREADABLE);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The cloud's `Clip`, checked field by field (it passes `toClip`'s shape through). */
|
|
56
|
+
const clipOf = (raw: unknown, where: string): Clip => {
|
|
57
|
+
const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
|
|
58
|
+
const id = str(c?.id);
|
|
59
|
+
if (!c || !id) return unreadable(where);
|
|
60
|
+
const playbackId = str(c.playbackId);
|
|
61
|
+
const errored = c.errored === true;
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
playbackId: playbackId && MUX_ID.test(playbackId) ? playbackId : null,
|
|
65
|
+
status: str(c.status) ?? 'unknown',
|
|
66
|
+
ready: c.ready === true,
|
|
67
|
+
errored,
|
|
68
|
+
error: errored ? (str(c.error) ?? 'Mux could not process this file.') : null,
|
|
69
|
+
title: str(c.title),
|
|
70
|
+
duration: num(c.duration),
|
|
71
|
+
aspectRatio: str(c.aspectRatio),
|
|
72
|
+
w: px(c.w),
|
|
73
|
+
h: px(c.h),
|
|
74
|
+
createdAt: str(c.createdAt),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const query = (id: string) => new URLSearchParams({ id }).toString();
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async createUpload(origin, title) {
|
|
82
|
+
const answer = await api('POST', '/v1/video/upload', { origin, title });
|
|
83
|
+
const id = str(answer.id);
|
|
84
|
+
const url = str(answer.url);
|
|
85
|
+
if (!id || !url || !/^https:\/\//.test(url)) return unreadable('POST /v1/video/upload');
|
|
86
|
+
return { id, url };
|
|
87
|
+
},
|
|
88
|
+
async upload(id): Promise<VideoUploadStatus> {
|
|
89
|
+
const answer = await api('GET', `/v1/video/upload?${query(id)}`);
|
|
90
|
+
const assetId = str(answer.assetId);
|
|
91
|
+
return {
|
|
92
|
+
id: str(answer.id) ?? id,
|
|
93
|
+
status: str(answer.status) ?? 'unknown',
|
|
94
|
+
assetId: assetId && MUX_ID.test(assetId) ? assetId : null,
|
|
95
|
+
error: str(answer.error),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async clip(assetId) {
|
|
99
|
+
return clipOf(await api('GET', `/v1/video/clip?${query(assetId)}`), 'GET /v1/video/clip');
|
|
100
|
+
},
|
|
101
|
+
async clipOfPlayback(playbackId) {
|
|
102
|
+
return clipOf(await api('GET', `/v1/video/playback?${query(playbackId)}`), 'GET /v1/video/playback');
|
|
103
|
+
},
|
|
104
|
+
async clips() {
|
|
105
|
+
const answer = await api('GET', '/v1/video/clips');
|
|
106
|
+
if (!Array.isArray(answer.clips)) return unreadable('GET /v1/video/clips');
|
|
107
|
+
return answer.clips.map((c) => clipOf(c, 'GET /v1/video/clips'));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
* `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
|
|
13
13
|
* - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
|
|
14
14
|
* one credential: the bucket is the cloud's (cloud-bucket.ts), the signing from the
|
|
15
|
-
* settings the process holds (../../../media.ts).
|
|
15
|
+
* settings the process holds (../../../media.ts).
|
|
16
|
+
* - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
|
|
17
|
+
* cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
|
|
18
|
+
* beside it is a problem sentence naming them (one credential, not two).
|
|
16
19
|
*/
|
|
17
20
|
import type { CloudConnection } from '../../../live/cloud.ts';
|
|
21
|
+
import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
|
|
18
22
|
import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
19
23
|
|
|
20
24
|
type Env = Record<string, string | undefined>;
|
|
@@ -33,7 +37,11 @@ export type ImagesConfig =
|
|
|
33
37
|
| { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
|
|
34
38
|
| { kind: 'unset'; message: string; missing: string[] };
|
|
35
39
|
|
|
36
|
-
export type MuxConfig =
|
|
40
|
+
export type MuxConfig =
|
|
41
|
+
| { kind: 'memory' }
|
|
42
|
+
| { kind: 'mux'; tokenId: string; tokenSecret: string }
|
|
43
|
+
| { kind: 'cloud'; connection: Pick<CloudConnection, 'key' | 'url'> }
|
|
44
|
+
| { kind: 'unset'; message: string; missing: string[] };
|
|
37
45
|
|
|
38
46
|
export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
|
|
39
47
|
|
|
@@ -77,9 +85,18 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
77
85
|
return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
/** The clip routes' settings: the site's Mux environment's token. */
|
|
88
|
+
/** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
|
|
81
89
|
export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
|
|
82
90
|
if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
91
|
+
const key = get(env, 'CARACMS_CLOUD_KEY');
|
|
92
|
+
if (key) {
|
|
93
|
+
const problems: string[] = [];
|
|
94
|
+
const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
|
|
95
|
+
if (both.length) problems.push(CLOUD_AND_MUX_PROBLEM(both));
|
|
96
|
+
const url = readCloudUrl(get(env, 'CARACMS_CLOUD_URL'), problems);
|
|
97
|
+
if (problems.length) return { kind: 'unset', message: problems.join(' '), missing: [] };
|
|
98
|
+
return { kind: 'cloud', connection: { key, url } };
|
|
99
|
+
}
|
|
83
100
|
const tokenId = get(env, 'MUX_TOKEN_ID');
|
|
84
101
|
const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
|
|
85
102
|
const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - **No lock** (Decision 7): an upload, or reading the library, changes no document.
|
|
11
11
|
* - **Every reply is `Cache-Control: private, no-store`.**
|
|
12
12
|
* - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
|
|
13
|
-
* imgproxy) and clips (Mux) apart: one missing never
|
|
13
|
+
* imgproxy) and clips (Mux, or CaraCMS Cloud's: cloud-mux.ts) apart: one missing never
|
|
14
|
+
* disables the other.
|
|
14
15
|
* - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
|
|
15
16
|
* only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
|
|
16
17
|
* - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
|
|
@@ -42,6 +43,7 @@ import { parseReplaceBody } from '../replace.ts';
|
|
|
42
43
|
import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
|
|
43
44
|
import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
|
|
44
45
|
import { cloudBucket } from './cloud-bucket.ts';
|
|
46
|
+
import { cloudMux } from './cloud-mux.ts';
|
|
45
47
|
import { isCloudRefusal } from '../../../live/cloud.ts';
|
|
46
48
|
import { readImagesConfig, readMuxConfig } from './config.ts';
|
|
47
49
|
import { isFileKey } from '../../../files.ts';
|
|
@@ -88,6 +90,8 @@ function mux(ctx: MediaContext): MuxClient | Response {
|
|
|
88
90
|
if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
|
|
89
91
|
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
90
92
|
if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
|
|
93
|
+
// caracms-cloud M4.5: the cloud's Mux, through the site key; its refusals arrive as MuxRouteErrors.
|
|
94
|
+
if (config.kind === 'cloud') return cloudMux(config.connection, { fetch: ctx.fetch, log });
|
|
91
95
|
return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -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/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';
|
package/src/live/mode.ts
CHANGED
|
@@ -101,6 +101,36 @@ export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
|
|
|
101
101
|
export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
|
|
102
102
|
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
103
103
|
export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
|
|
104
|
+
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
105
|
+
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
106
|
+
export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
|
|
107
|
+
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
108
|
+
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
109
|
+
export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
|
|
110
|
+
|
|
111
|
+
/** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
|
|
112
|
+
export function cloudFormsConflict(env: Env): string | null {
|
|
113
|
+
if (!env.CARACMS_CLOUD_KEY?.trim()) return null;
|
|
114
|
+
const both = OWN_FORMS_VARIABLES.filter((name) => env[name]?.trim());
|
|
115
|
+
return both.length ? CLOUD_AND_FORMS_PROBLEM(both) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The cloud as the forms and email credential (caracms-cloud M4 Decision 22): the
|
|
120
|
+
* connection when `CARACMS_CLOUD_KEY` is set, with any problems (the site's own forms and
|
|
121
|
+
* email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
|
|
122
|
+
* the cloud is *used* is the caller's (only on a production server; the Core framing).
|
|
123
|
+
*/
|
|
124
|
+
export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
125
|
+
const get = (name: string) => env[name]?.trim() || null;
|
|
126
|
+
const key = get('CARACMS_CLOUD_KEY');
|
|
127
|
+
if (!key) return null;
|
|
128
|
+
const conflict = cloudFormsConflict(env);
|
|
129
|
+
const problems: string[] = conflict ? [conflict] : [];
|
|
130
|
+
const url = readCloudUrl(get('CARACMS_CLOUD_URL'), problems);
|
|
131
|
+
return { connection: { key, url }, problems };
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
|
|
105
135
|
export const CLOUD_URL_PROBLEM = (url: string) =>
|
|
106
136
|
`CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
|
|
@@ -194,6 +224,12 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
194
224
|
// M3 Decision 19: the key is the media credential too (unless the tests' in-memory media is on).
|
|
195
225
|
const ownMedia = OWN_MEDIA_VARIABLES.filter((name) => get(name));
|
|
196
226
|
if (ownMedia.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MEDIA_PROBLEM(ownMedia));
|
|
227
|
+
// M4.5 Decision 15: and the video credential.
|
|
228
|
+
const ownMux = OWN_MUX_VARIABLES.filter((name) => get(name));
|
|
229
|
+
if (ownMux.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MUX_PROBLEM(ownMux));
|
|
230
|
+
// M4 Decision 22: and the forms and email credential.
|
|
231
|
+
const formsConflict = cloudFormsConflict(env);
|
|
232
|
+
if (formsConflict) problems.push(formsConflict);
|
|
197
233
|
github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
|
|
198
234
|
} else if (!memory || production) {
|
|
199
235
|
const appId = get('GITHUB_APP_ID');
|