@stelstone/server 0.26.3 → 0.28.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/package.json +2 -2
- package/src/adapters/build-github-actions.mjs +104 -0
- package/src/adapters/index.mjs +1 -0
- package/src/core/adapter-options.mjs +62 -10
- package/src/core/config-schema.mjs +101 -3
- package/src/core/forms.mjs +45 -0
- package/src/routes.mjs +35 -3
- package/src/server.mjs +7 -2
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stelstone/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"type": "module",
|
|
19
19
|
"bin": {
|
|
20
|
-
"stelstone": "
|
|
20
|
+
"stelstone": "bin/stelstone.mjs"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "node --test 'test/**/*.test.mjs'"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow runs as the deploy status source.
|
|
3
|
+
*
|
|
4
|
+
* For sites whose CI does the deploying — a Worker, a container, an rsync to
|
|
5
|
+
* a VPS — the workflow run IS the deploy, so its state is the honest thing to
|
|
6
|
+
* show. The adapter speaks the same vocabulary as the Netlify one so the
|
|
7
|
+
* admin's pill needs no per-provider knowledge.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} opts
|
|
10
|
+
* @param {string|undefined} opts.token repo-scoped token; `actions:read`
|
|
11
|
+
* @param {string|undefined} opts.owner
|
|
12
|
+
* @param {string|undefined} opts.repo
|
|
13
|
+
* @param {string|undefined} opts.workflow file name ("deploy.yml") or id;
|
|
14
|
+
* omit to watch every workflow
|
|
15
|
+
* @param {string|undefined} opts.defaultBranch
|
|
16
|
+
* @returns {import('./types.mjs').BuildAdapter}
|
|
17
|
+
*/
|
|
18
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A finished run's conclusion, in the admin's state vocabulary.
|
|
22
|
+
*
|
|
23
|
+
* Anything that did not succeed but also did not fail outright — skipped by a
|
|
24
|
+
* path filter, superseded, stopped by hand — reads as "cancelled": the deploy
|
|
25
|
+
* did not happen, and that is not the same as a broken build.
|
|
26
|
+
*/
|
|
27
|
+
const CONCLUSION_STATE = {
|
|
28
|
+
success: "ready",
|
|
29
|
+
failure: "error",
|
|
30
|
+
timed_out: "error",
|
|
31
|
+
startup_failure: "error",
|
|
32
|
+
action_required: "error",
|
|
33
|
+
cancelled: "cancelled",
|
|
34
|
+
skipped: "cancelled",
|
|
35
|
+
stale: "cancelled",
|
|
36
|
+
neutral: "cancelled",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Queued and waiting-for-approval runs have not started; both are "queued". */
|
|
40
|
+
const PENDING_STATUSES = new Set(["queued", "pending", "waiting", "requested"]);
|
|
41
|
+
|
|
42
|
+
function runState(run) {
|
|
43
|
+
if (run.status === "completed") return CONCLUSION_STATE[run.conclusion] || "error";
|
|
44
|
+
return PENDING_STATUSES.has(run.status) ? "enqueued" : "building";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createGitHubActionsBuild({ token, owner, repo, workflow, defaultBranch }) {
|
|
48
|
+
const configured = Boolean(token && owner && repo);
|
|
49
|
+
const api = configured ? createGitHubApi({ token, owner, repo }) : null;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
configured,
|
|
53
|
+
|
|
54
|
+
async getDeployStatus({ branch, sha } = {}) {
|
|
55
|
+
if (!configured) return { configured: false };
|
|
56
|
+
|
|
57
|
+
const query = new URLSearchParams({ per_page: "20" });
|
|
58
|
+
const targetBranch = branch || defaultBranch;
|
|
59
|
+
if (targetBranch) query.set("branch", targetBranch);
|
|
60
|
+
const path = workflow
|
|
61
|
+
? `/actions/workflows/${encodeURIComponent(workflow)}/runs?${query}`
|
|
62
|
+
: `/actions/runs?${query}`;
|
|
63
|
+
|
|
64
|
+
const body = await api.apiGet(path);
|
|
65
|
+
// apiGet answers null on 404 — for a run listing that means the repo,
|
|
66
|
+
// the workflow name or the token is wrong, not "no runs yet". Say so
|
|
67
|
+
// instead of returning an empty list the caller would poll forever.
|
|
68
|
+
if (!body) {
|
|
69
|
+
const err = new Error(`GitHub API 404 GET ${path}`);
|
|
70
|
+
err.upstreamStatus = 404;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runs = body.workflow_runs || [];
|
|
75
|
+
let run = null;
|
|
76
|
+
if (sha) {
|
|
77
|
+
run = runs.find((r) => r.head_sha && r.head_sha.startsWith(sha));
|
|
78
|
+
// SHA not there yet — the push has not produced a run, keep waiting.
|
|
79
|
+
if (!run) return { configured: true, deploy: null };
|
|
80
|
+
}
|
|
81
|
+
if (!run) run = runs[0] || null;
|
|
82
|
+
if (!run) return { configured: true, deploy: null };
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
configured: true,
|
|
86
|
+
deploy: {
|
|
87
|
+
id: run.id,
|
|
88
|
+
state: runState(run),
|
|
89
|
+
branch: run.head_branch,
|
|
90
|
+
commitRef: run.head_sha,
|
|
91
|
+
// Actions knows what it ran, not where the result is published, so
|
|
92
|
+
// the run page is the only link worth offering.
|
|
93
|
+
deployUrl: null,
|
|
94
|
+
adminUrl: run.html_url || null,
|
|
95
|
+
createdAt: run.created_at,
|
|
96
|
+
updatedAt: run.updated_at,
|
|
97
|
+
// A run carries no failure text; the run page has the logs.
|
|
98
|
+
errorMessage: null,
|
|
99
|
+
title: run.display_title || run.name || null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/adapters/index.mjs
CHANGED
|
@@ -8,5 +8,6 @@ export { createBasicAuth } from "./basic-auth.mjs";
|
|
|
8
8
|
export { createGitHubOAuth } from "./github-oauth.mjs";
|
|
9
9
|
export { createCloudflareAccess } from "./cloudflare-access.mjs";
|
|
10
10
|
export { createNetlifyBuild } from "./build-netlify.mjs";
|
|
11
|
+
export { createGitHubActionsBuild } from "./build-github-actions.mjs";
|
|
11
12
|
export { createMediaUrl } from "./media-url.mjs";
|
|
12
13
|
export { createResendMail } from "./resend-mail.mjs";
|
|
@@ -16,20 +16,29 @@
|
|
|
16
16
|
/** @typedef {(name: string) => string|undefined} SecretLookup */
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
|
|
20
|
+
* already live, so a deploy-button user never edits a file. Lenient about
|
|
21
|
+
* pasted URLs: the last two path segments are the coordinates.
|
|
22
|
+
*
|
|
21
23
|
* @param {SecretLookup} secrets
|
|
24
|
+
* @returns {{owner?: string, repo?: string}}
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// already live, so a deploy-button user never edits a file. Lenient about
|
|
26
|
-
// pasted URLs: the last two path segments are the coordinates.
|
|
27
|
-
const repoEnv = String(secrets("GITHUB_REPO") ?? "")
|
|
26
|
+
function repoFromEnv(secrets) {
|
|
27
|
+
const parts = String(secrets("GITHUB_REPO") ?? "")
|
|
28
28
|
.replace(/\.git$/, "")
|
|
29
29
|
.split(/[/:]/)
|
|
30
30
|
.filter(Boolean)
|
|
31
31
|
.slice(-2);
|
|
32
|
-
|
|
32
|
+
return parts.length === 2 ? { owner: parts[0], repo: parts[1] } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for `createGitHubContent`.
|
|
37
|
+
* @param {Object} config
|
|
38
|
+
* @param {SecretLookup} secrets
|
|
39
|
+
*/
|
|
40
|
+
export function githubContentOptions(config, secrets) {
|
|
41
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
33
42
|
return {
|
|
34
43
|
token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
|
|
35
44
|
owner: envOwner || config.content.owner,
|
|
@@ -135,11 +144,54 @@ export function netlifyBuildOptions(config, secrets) {
|
|
|
135
144
|
return {
|
|
136
145
|
token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
|
|
137
146
|
siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
|
|
138
|
-
defaultBranch:
|
|
139
|
-
secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
|
|
147
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
140
148
|
};
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The branch whose deploy the admin's status pill follows. `STAGING_BRANCH`
|
|
153
|
+
* wins for the same reason it does for content: it is what redirects a whole
|
|
154
|
+
* deployment onto another branch.
|
|
155
|
+
*/
|
|
156
|
+
function buildDefaultBranch(config, secrets) {
|
|
157
|
+
return secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Which build adapter to construct, and with what.
|
|
162
|
+
*
|
|
163
|
+
* Mirrors `authOptions`: the caller stays free of the naming rules and only
|
|
164
|
+
* has to map a provider name onto a factory. Netlify remains the default so
|
|
165
|
+
* existing configs keep working untouched.
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} config
|
|
168
|
+
* @param {SecretLookup} secrets
|
|
169
|
+
* @returns {{provider: string, options: object}}
|
|
170
|
+
*/
|
|
171
|
+
export function buildOptions(config, secrets) {
|
|
172
|
+
const build = config.build ?? {};
|
|
173
|
+
const provider = build.provider ?? "netlify";
|
|
174
|
+
|
|
175
|
+
if (provider === "github-actions") {
|
|
176
|
+
// Defaults to the repo the content already comes from — for a site whose
|
|
177
|
+
// CI deploys it, that is nearly always the same repo, and the token that
|
|
178
|
+
// reads content can read its runs.
|
|
179
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
180
|
+
return {
|
|
181
|
+
provider,
|
|
182
|
+
options: {
|
|
183
|
+
token: secrets(build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN"),
|
|
184
|
+
owner: envOwner || build.owner || config.content?.owner,
|
|
185
|
+
repo: envRepo || build.repo || config.content?.repo,
|
|
186
|
+
workflow: build.workflow,
|
|
187
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { provider: "netlify", options: netlifyBuildOptions(config, secrets) };
|
|
193
|
+
}
|
|
194
|
+
|
|
143
195
|
/**
|
|
144
196
|
* Options for `createCdnProxyMedia`.
|
|
145
197
|
* @param {Object} config
|
|
@@ -247,7 +247,9 @@ function checkForms(config, report, { getSecret }) {
|
|
|
247
247
|
report.error("forms", "must be an object of formName → definition");
|
|
248
248
|
return;
|
|
249
249
|
}
|
|
250
|
-
|
|
250
|
+
// A form with a forward has somewhere to put a submission even without mail.
|
|
251
|
+
const needsMail = Object.values(forms).some((def) => !isPlainObject(def) || !def.forward);
|
|
252
|
+
if (needsMail && !mail) {
|
|
251
253
|
report.warn("forms", "defined without config.mail — submissions will answer 503 until mail is configured");
|
|
252
254
|
}
|
|
253
255
|
for (const [name, def] of Object.entries(forms)) {
|
|
@@ -256,8 +258,12 @@ function checkForms(config, report, { getSecret }) {
|
|
|
256
258
|
report.error(at, "must be an object");
|
|
257
259
|
continue;
|
|
258
260
|
}
|
|
259
|
-
|
|
261
|
+
// `to` is what the e-mail copy needs. A form that only forwards has
|
|
262
|
+
// somewhere to put a submission without one.
|
|
263
|
+
if (def.to === undefined && !def.forward) {
|
|
260
264
|
report.error(`${at}.to`, "is required and must be an email address");
|
|
265
|
+
} else if (def.to !== undefined && (typeof def.to !== "string" || !def.to.includes("@"))) {
|
|
266
|
+
report.error(`${at}.to`, "must be an email address");
|
|
261
267
|
}
|
|
262
268
|
if (def.subject !== undefined && typeof def.subject !== "string" && typeof def.subject !== "function") {
|
|
263
269
|
report.error(`${at}.subject`, "must be a string or a (fields) => string function");
|
|
@@ -271,6 +277,94 @@ function checkForms(config, report, { getSecret }) {
|
|
|
271
277
|
report.warn(`${at}.turnstile`, `${secretEnv} is not set — verification will answer 503`);
|
|
272
278
|
}
|
|
273
279
|
}
|
|
280
|
+
checkForward(def.forward, `${at}.forward`, report, getSecret);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `forward`: post the submission on to another service, e.g. a CRM. */
|
|
285
|
+
function checkForward(forward, at, report, getSecret) {
|
|
286
|
+
if (forward === undefined) return;
|
|
287
|
+
if (!isPlainObject(forward)) {
|
|
288
|
+
report.error(at, "must be an object like { url, keyEnv?, body? }");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let url;
|
|
293
|
+
try {
|
|
294
|
+
url = new URL(forward.url);
|
|
295
|
+
} catch {
|
|
296
|
+
report.error(`${at}.url`, "is required and must be an absolute http(s) URL");
|
|
297
|
+
}
|
|
298
|
+
if (forward.urlEnv !== undefined && typeof forward.urlEnv !== "string") {
|
|
299
|
+
report.error(`${at}.urlEnv`, "must be the NAME of an environment variable overriding url");
|
|
300
|
+
}
|
|
301
|
+
if (url && url.protocol !== "https:" && url.hostname !== "localhost") {
|
|
302
|
+
report.error(`${at}.url`, "must use https outside localhost — the payload carries personal data");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (forward.body !== undefined && typeof forward.body !== "function") {
|
|
306
|
+
report.error(`${at}.body`, "must be a (fields) => object function");
|
|
307
|
+
}
|
|
308
|
+
if (forward.headers !== undefined && !isPlainObject(forward.headers)) {
|
|
309
|
+
report.error(`${at}.headers`, "must be an object of header → value");
|
|
310
|
+
}
|
|
311
|
+
if (forward.keyHeader !== undefined && typeof forward.keyHeader !== "string") {
|
|
312
|
+
report.error(`${at}.keyHeader`, "must be a header name like \"x-api-key\"");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (forward.keyEnv === undefined) {
|
|
316
|
+
report.warn(`${at}.keyEnv`, "is not set — the request will carry no credential");
|
|
317
|
+
} else if (typeof forward.keyEnv !== "string") {
|
|
318
|
+
report.error(`${at}.keyEnv`, "must be the NAME of the environment variable holding the key");
|
|
319
|
+
} else if (!getSecret(forward.keyEnv)) {
|
|
320
|
+
report.warn(`${at}.keyEnv`, `${forward.keyEnv} is not set — forwarding will fail`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `build` decides where the admin's deploy pill gets its status. It is
|
|
326
|
+
* optional — a site with no CI simply shows no pill — but a misspelled
|
|
327
|
+
* provider silently falls back to Netlify, which for a site that does not use
|
|
328
|
+
* Netlify means a pill that never resolves. Name it.
|
|
329
|
+
*/
|
|
330
|
+
function checkBuild(config, report, { getSecret }) {
|
|
331
|
+
const build = config.build;
|
|
332
|
+
if (build === undefined) return;
|
|
333
|
+
if (typeof build !== "object" || build === null) {
|
|
334
|
+
report.error("build", "must be an object");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const provider = build.provider ?? "netlify";
|
|
339
|
+
if (!["netlify", "github-actions"].includes(provider)) {
|
|
340
|
+
report.error("build.provider", `unknown provider "${provider}" — expected netlify or github-actions`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (provider === "github-actions") {
|
|
345
|
+
const owner = build.owner || config.content?.owner;
|
|
346
|
+
const repo = build.repo || config.content?.repo;
|
|
347
|
+
// GITHUB_REPO can supply both at runtime, so this is a warning: the
|
|
348
|
+
// coordinates may well arrive from the environment.
|
|
349
|
+
if (!owner || !repo) {
|
|
350
|
+
report.warn(
|
|
351
|
+
"build",
|
|
352
|
+
"owner/repo not in the config — deploy status needs them, from build.owner/build.repo, content, or GITHUB_REPO",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const tokenEnv = build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN";
|
|
356
|
+
if (!getSecret(tokenEnv)) {
|
|
357
|
+
report.warn("build.githubTokenEnv", `${tokenEnv} is not set — deploy status is unavailable`);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const [key, fallback] of [
|
|
363
|
+
["netlifyTokenEnv", "NETLIFY_AUTH_TOKEN"],
|
|
364
|
+
["netlifySiteIdEnv", "NETLIFY_SITE_ID"],
|
|
365
|
+
]) {
|
|
366
|
+
const name = build[key] || fallback;
|
|
367
|
+
if (!getSecret(name)) report.warn(`build.${key}`, `${name} is not set — deploy status is unavailable`);
|
|
274
368
|
}
|
|
275
369
|
}
|
|
276
370
|
|
|
@@ -325,6 +419,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
|
325
419
|
checkAuth(config, report, { getSecret: lookup });
|
|
326
420
|
checkCollections(config, report);
|
|
327
421
|
checkForms(config, report, { getSecret: lookup });
|
|
422
|
+
checkBuild(config, report, { getSecret: lookup });
|
|
328
423
|
checkMisc(config, report);
|
|
329
424
|
|
|
330
425
|
return { errors: report.errors, warnings: report.warnings };
|
|
@@ -398,7 +493,10 @@ export function describeConfig(config, { adapters, getSecret, runtime = "node" }
|
|
|
398
493
|
lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
|
|
399
494
|
}
|
|
400
495
|
if (adapters?.build) {
|
|
401
|
-
|
|
496
|
+
const buildProvider = config.build?.provider ?? "netlify";
|
|
497
|
+
lines.push(
|
|
498
|
+
`build ${buildProvider} — ${adapters.build.configured ? "configured" : "not configured"}`,
|
|
499
|
+
);
|
|
402
500
|
}
|
|
403
501
|
|
|
404
502
|
const collections = Object.keys(config.collections || {});
|
package/src/core/forms.mjs
CHANGED
|
@@ -64,6 +64,51 @@ export function formatMessage(fields, { skip = [] } = {}) {
|
|
|
64
64
|
.join("\n");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The fields a sink should see: no honeypot, no spent Turnstile token. */
|
|
68
|
+
export function submissionFields(fields, honeypot) {
|
|
69
|
+
const hidden = new Set([honeypot, "form-name", "cf-turnstile-response"]);
|
|
70
|
+
return Object.fromEntries(Object.entries(fields).filter(([key]) => !hidden.has(key)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Posts a submission on to another HTTP service — a CRM, an automation hook,
|
|
75
|
+
* whatever the site points it at. The secret travels by env name so it lives
|
|
76
|
+
* in the deployment rather than in cms.config, and the shape is the site's to
|
|
77
|
+
* decide: `body(fields)` maps the form onto whatever the far end expects, and
|
|
78
|
+
* without it the cleaned fields go as they are.
|
|
79
|
+
*
|
|
80
|
+
* Throws on anything but a 2xx; the caller decides what a failure means.
|
|
81
|
+
*/
|
|
82
|
+
export async function postForward(forward, fields, env) {
|
|
83
|
+
// `urlEnv` wins when the deployment sets it, so one config can point a
|
|
84
|
+
// preview worker at a staging service without editing the file. A Worker
|
|
85
|
+
// reads bindings rather than process.env, which is why this is a name to
|
|
86
|
+
// look up rather than something the config computes at import time.
|
|
87
|
+
const url = (forward.urlEnv && env(forward.urlEnv)) || forward.url;
|
|
88
|
+
const headers = { "content-type": "application/json", ...(forward.headers ?? {}) };
|
|
89
|
+
if (forward.keyEnv) {
|
|
90
|
+
const key = env(forward.keyEnv);
|
|
91
|
+
if (!key) throw new Error(`${forward.keyEnv} is not set`);
|
|
92
|
+
headers[forward.keyHeader ?? "x-api-key"] = key;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = typeof forward.body === "function" ? forward.body(fields) : fields;
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers,
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
// Keep the far end's own words — a 400 from a CRM usually names the field
|
|
103
|
+
// it rejected, and that is what makes the failure fixable.
|
|
104
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300).trim();
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Forward to ${new URL(url).host} failed with HTTP ${res.status}` +
|
|
107
|
+
(detail ? `: ${detail}` : ""),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
/**
|
|
68
113
|
* Fixed-window in-memory rate limiter for the Node runtime. Per-process by
|
|
69
114
|
* design — the Node server is a single process, and forms are a trickle.
|
package/src/routes.mjs
CHANGED
|
@@ -26,7 +26,14 @@
|
|
|
26
26
|
|
|
27
27
|
import { queryPages } from "./adapters/_shared.mjs";
|
|
28
28
|
import { SERVER_VERSION } from "./version.mjs";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
readFormFields,
|
|
31
|
+
fieldsError,
|
|
32
|
+
formatMessage,
|
|
33
|
+
clientIp,
|
|
34
|
+
submissionFields,
|
|
35
|
+
postForward,
|
|
36
|
+
} from "./core/forms.mjs";
|
|
30
37
|
|
|
31
38
|
function ok(json) {
|
|
32
39
|
return { json };
|
|
@@ -516,7 +523,26 @@ export const apiRoutes = [
|
|
|
516
523
|
return { status: 429, json: { error: "Too many submissions — please wait a minute" } };
|
|
517
524
|
}
|
|
518
525
|
|
|
519
|
-
|
|
526
|
+
// Two sinks, and a submission survives on either one. The forward runs
|
|
527
|
+
// first so that when it fails the e-mail can carry the reason to whoever
|
|
528
|
+
// reads it — otherwise a CRM outage is invisible until someone notices
|
|
529
|
+
// the leads stopped arriving.
|
|
530
|
+
let forwardError = null;
|
|
531
|
+
if (def.forward) {
|
|
532
|
+
try {
|
|
533
|
+
await postForward(def.forward, submissionFields(fields, honeypot), env);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
forwardError = err.message;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const forwarded = !!def.forward && !forwardError;
|
|
539
|
+
|
|
540
|
+
// No `to` means the form was set up to forward only — there is no
|
|
541
|
+
// e-mail copy to send, and nothing missing.
|
|
542
|
+
const mailReady = !!def.to && !!adapters.mail?.configured;
|
|
543
|
+
if (!mailReady) {
|
|
544
|
+
if (forwarded) return success();
|
|
545
|
+
if (forwardError) return { status: 502, json: { error: forwardError } };
|
|
520
546
|
return {
|
|
521
547
|
status: 503,
|
|
522
548
|
json: { error: "Mail delivery is not configured — set config.mail and its API key" },
|
|
@@ -528,15 +554,21 @@ export const apiRoutes = [
|
|
|
528
554
|
? def.subject(fields)
|
|
529
555
|
: def.subject || `Yeni form gönderimi: ${params.name}`;
|
|
530
556
|
const replyTo = def.replyTo ? fields[def.replyTo] : undefined;
|
|
557
|
+
const text = forwardError
|
|
558
|
+
? `${formatMessage(fields, { skip: [honeypot] })}\n\n---\nBu gönderim CRM'e iletilemedi: ${forwardError}`
|
|
559
|
+
: formatMessage(fields, { skip: [honeypot] });
|
|
531
560
|
|
|
532
561
|
try {
|
|
533
562
|
await adapters.mail.send({
|
|
534
563
|
to: def.to,
|
|
535
564
|
subject,
|
|
536
|
-
text
|
|
565
|
+
text,
|
|
537
566
|
...(replyTo ? { replyTo } : {}),
|
|
538
567
|
});
|
|
539
568
|
} catch (err) {
|
|
569
|
+
// The forward already has the submission, so the visitor has nothing
|
|
570
|
+
// to retry — only the copy for humans went missing.
|
|
571
|
+
if (forwarded) return success();
|
|
540
572
|
return { status: 502, json: { error: err.message } };
|
|
541
573
|
}
|
|
542
574
|
return success();
|
package/src/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
fsContentOptions,
|
|
13
13
|
githubTemplatesOptions,
|
|
14
14
|
authOptions,
|
|
15
|
-
|
|
15
|
+
buildOptions,
|
|
16
16
|
cdnMediaOptions,
|
|
17
17
|
mailOptions,
|
|
18
18
|
} from "./core/adapter-options.mjs";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createGitHubOAuth,
|
|
26
26
|
createCloudflareAccess,
|
|
27
27
|
createNetlifyBuild,
|
|
28
|
+
createGitHubActionsBuild,
|
|
28
29
|
createFsTemplates,
|
|
29
30
|
createGitHubTemplates,
|
|
30
31
|
createResendMail,
|
|
@@ -79,7 +80,11 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
79
80
|
// No media config → no CDN adapter; the media routes answer 404 instead
|
|
80
81
|
// of every /api request dying while the adapter bag is built.
|
|
81
82
|
const cdnMedia = config.media ? createCdnProxyMedia(cdnMediaOptions(config, auth)) : undefined;
|
|
82
|
-
const
|
|
83
|
+
const { provider: buildProvider, options: buildOpts } = buildOptions(config, secrets);
|
|
84
|
+
const build =
|
|
85
|
+
buildProvider === "github-actions"
|
|
86
|
+
? createGitHubActionsBuild(buildOpts)
|
|
87
|
+
: createNetlifyBuild(buildOpts);
|
|
83
88
|
|
|
84
89
|
const templates = useGitHub
|
|
85
90
|
? createGitHubTemplates(githubTemplatesOptions(config, secrets))
|
package/src/version.mjs
CHANGED