@stelstone/server 0.26.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 +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
package/src/routes.mjs
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic API routes for the Stelstone.
|
|
3
|
+
*
|
|
4
|
+
* Each route is `{ method, path, auth, handler }`:
|
|
5
|
+
* - `path` uses Express-style `:param` placeholders.
|
|
6
|
+
* - `auth` is `"public"` (no token required), `"any"` (any logged-in user),
|
|
7
|
+
* or `"admin"` (admin role).
|
|
8
|
+
* - `handler(ctx)` returns a `ResponseSpec`.
|
|
9
|
+
*
|
|
10
|
+
* Ctx shape (provided by the runtime shim):
|
|
11
|
+
* {
|
|
12
|
+
* params, // route params
|
|
13
|
+
* query, // parsed query object
|
|
14
|
+
* body, // parsed JSON body (or null)
|
|
15
|
+
* user, // cmsUser or null
|
|
16
|
+
* header(name), // request header lookup (case-insensitive)
|
|
17
|
+
* env(name), // env var lookup
|
|
18
|
+
* adapters, // { content, templates, cdnMedia, auth, build }
|
|
19
|
+
* config, // cms.config
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* ResponseSpec:
|
|
23
|
+
* { status?, json?, text?, redirect?, headers? }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
import { queryPages } from "./adapters/_shared.mjs";
|
|
28
|
+
import { SERVER_VERSION } from "./version.mjs";
|
|
29
|
+
import { readFormFields, fieldsError, formatMessage, clientIp } from "./core/forms.mjs";
|
|
30
|
+
|
|
31
|
+
function ok(json) {
|
|
32
|
+
return { json };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function notFound(error = "Not found") {
|
|
36
|
+
return { status: 404, json: { error } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function badRequest(error) {
|
|
40
|
+
return { status: 400, json: { error } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Light structural validation for page payloads (PUT/POST).
|
|
45
|
+
* Returns an error string, or null when the payload looks sane.
|
|
46
|
+
* Not a schema check — just enough to keep malformed requests from
|
|
47
|
+
* writing broken JSON into a collection.
|
|
48
|
+
*/
|
|
49
|
+
function pageBodyError(body) {
|
|
50
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return "Body must be a JSON object";
|
|
51
|
+
if (body.slug !== undefined && typeof body.slug !== "string") return "slug must be a string";
|
|
52
|
+
if (body.meta !== undefined && (typeof body.meta !== "object" || Array.isArray(body.meta))) {
|
|
53
|
+
return "meta must be an object";
|
|
54
|
+
}
|
|
55
|
+
if (body.blocks !== undefined) {
|
|
56
|
+
if (!Array.isArray(body.blocks)) return "blocks must be an array";
|
|
57
|
+
for (const b of body.blocks) {
|
|
58
|
+
if (!b || typeof b !== "object" || typeof b.type !== "string") {
|
|
59
|
+
return "each block must be an object with a string type";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const apiRoutes = [
|
|
67
|
+
// ── About ──────────────────────────────────────────────────────────────
|
|
68
|
+
{
|
|
69
|
+
method: "GET",
|
|
70
|
+
path: "/api/about",
|
|
71
|
+
auth: "public",
|
|
72
|
+
// Answers "what is actually running here?" in one request, instead of
|
|
73
|
+
// inferring it from file timestamps and bundle hashes. gitSha/buildTime
|
|
74
|
+
// are reported when the deployment supplies them.
|
|
75
|
+
// Reports only what this server can actually know. The admin UI and the
|
|
76
|
+
// block registry are separate packages resolved elsewhere, so their
|
|
77
|
+
// versions are supplied by the runtime that resolved them — or reported as
|
|
78
|
+
// null rather than guessed. On a Worker the SPA is served by the assets
|
|
79
|
+
// binding, which the CMS never sees, so null is the honest answer there.
|
|
80
|
+
handler: ({ env, runtime, adminUiVersion }) =>
|
|
81
|
+
ok({
|
|
82
|
+
serverVersion: SERVER_VERSION,
|
|
83
|
+
adminUiVersion: adminUiVersion ?? null,
|
|
84
|
+
// Declared by whoever constructed the handler, not sniffed: with
|
|
85
|
+
// nodejs_compat enabled a Worker also exposes process.versions.node,
|
|
86
|
+
// so feature detection reported "node" from inside a Worker.
|
|
87
|
+
runtime,
|
|
88
|
+
gitSha: env("CMS_GIT_SHA") ?? null,
|
|
89
|
+
buildTime: env("CMS_BUILD_TIME") ?? null,
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
94
|
+
{
|
|
95
|
+
method: "GET",
|
|
96
|
+
path: "/api/config",
|
|
97
|
+
auth: "public",
|
|
98
|
+
handler: ({ adapters }) => ok(adapters.publicConfig()),
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
method: "GET",
|
|
103
|
+
path: "/api/me",
|
|
104
|
+
auth: "any",
|
|
105
|
+
handler: ({ user }) => ok(user || { login: "admin", role: "admin" }),
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// ── Collections ────────────────────────────────────────────────────────
|
|
109
|
+
{
|
|
110
|
+
method: "GET",
|
|
111
|
+
path: "/api/collections",
|
|
112
|
+
auth: "any",
|
|
113
|
+
handler: async ({ adapters }) => ok(await adapters.content.listCollections()),
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
// Every page's public path, for the `link` field's picker. Paths follow
|
|
118
|
+
// the ancestor-chain convention (an entry whose meta.parent names another
|
|
119
|
+
// entry's slug is served under it) — the same convention
|
|
120
|
+
// stelstone/redirects uses to generate redirects. A site with
|
|
121
|
+
// different routing overrides it with config.linkPathOf({collection, data,
|
|
122
|
+
// all}) — a server-side function, so it never has to survive JSON.
|
|
123
|
+
method: "GET",
|
|
124
|
+
path: "/api/links",
|
|
125
|
+
auth: "any",
|
|
126
|
+
handler: async ({ adapters, config }) => {
|
|
127
|
+
const collections = await adapters.content.listCollections();
|
|
128
|
+
const all = [];
|
|
129
|
+
for (const { name } of collections) {
|
|
130
|
+
const pages = (await adapters.content.listPages(name)) ?? [];
|
|
131
|
+
for (const page of pages) {
|
|
132
|
+
const data = await adapters.content.readPage(name, page.file);
|
|
133
|
+
if (data) all.push({ collection: name, file: page.file, data });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const bySlug = new Map(all.map((e) => [e.data.slug, e]));
|
|
138
|
+
const defaultPathOf = (entry) => {
|
|
139
|
+
const parts = [entry.data.slug];
|
|
140
|
+
const seen = new Set([entry.data.slug]);
|
|
141
|
+
let cur = entry;
|
|
142
|
+
while (true) {
|
|
143
|
+
const parentSlug = cur.data.meta?.parent;
|
|
144
|
+
if (!parentSlug || seen.has(parentSlug)) break;
|
|
145
|
+
const parent = bySlug.get(parentSlug);
|
|
146
|
+
if (!parent) break;
|
|
147
|
+
parts.unshift(parent.data.slug);
|
|
148
|
+
seen.add(parentSlug);
|
|
149
|
+
cur = parent;
|
|
150
|
+
}
|
|
151
|
+
return `/${parts.join("/")}/`;
|
|
152
|
+
};
|
|
153
|
+
const pathOf = typeof config.linkPathOf === "function"
|
|
154
|
+
? (e) => config.linkPathOf({ collection: e.collection, data: e.data, all })
|
|
155
|
+
: defaultPathOf;
|
|
156
|
+
|
|
157
|
+
const links = all
|
|
158
|
+
.map((e) => ({
|
|
159
|
+
label: e.data.meta?.title || e.data.meta?.name || e.data.slug,
|
|
160
|
+
path: pathOf(e),
|
|
161
|
+
collection: e.collection,
|
|
162
|
+
file: e.file,
|
|
163
|
+
}))
|
|
164
|
+
.filter((l) => typeof l.path === "string" && l.path.startsWith("/"))
|
|
165
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
166
|
+
return ok(links);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
method: "GET",
|
|
172
|
+
path: "/api/collections/:collection",
|
|
173
|
+
auth: "any",
|
|
174
|
+
handler: async ({ adapters, config, params, query }) => {
|
|
175
|
+
const col = params.collection;
|
|
176
|
+
// ?sortField and ?sortDir override the collection's static sort config
|
|
177
|
+
const sortConfig = query.sortField
|
|
178
|
+
? { field: query.sortField, direction: query.sortDir === "desc" ? "desc" : "asc" }
|
|
179
|
+
: (config.collections?.[col]?.sort ?? null);
|
|
180
|
+
const pages = await adapters.content.listPages(col, sortConfig);
|
|
181
|
+
if (pages === null) return notFound();
|
|
182
|
+
|
|
183
|
+
// Parse pagination params
|
|
184
|
+
const rawPerPage = query.perPage ?? query.per_page;
|
|
185
|
+
const perPage = rawPerPage === "all" ? "all" : Math.max(1, parseInt(rawPerPage, 10) || 20);
|
|
186
|
+
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
|
187
|
+
const search = query.search ?? "";
|
|
188
|
+
|
|
189
|
+
// Build active filters from f_* query keys
|
|
190
|
+
const filters = {};
|
|
191
|
+
for (const [k, v] of Object.entries(query)) {
|
|
192
|
+
if (k.startsWith("f_") && v) filters[k.slice(2)] = v;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const colConfig = config.collections?.[col];
|
|
196
|
+
const searchFields = (colConfig?.listFields ?? [{ key: "title" }, { key: "lang" }, { key: "slug" }]).map((f) => f.key);
|
|
197
|
+
const filterKeys = (colConfig?.filters ?? []).map((f) => f.key);
|
|
198
|
+
|
|
199
|
+
const { items, total, facets } = queryPages(pages, { page, perPage, search, searchFields, filterKeys, filters });
|
|
200
|
+
return ok({ items, total, page, perPage, facets });
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
method: "GET",
|
|
206
|
+
path: "/api/collections/:collection/:file",
|
|
207
|
+
auth: "any",
|
|
208
|
+
handler: async ({ adapters, params }) => {
|
|
209
|
+
const data = await adapters.content.readPage(params.collection, params.file);
|
|
210
|
+
if (!data) return notFound();
|
|
211
|
+
// The version token travels as an ETag so the editor can send it back as
|
|
212
|
+
// If-Match when saving, turning a silent overwrite into a 412.
|
|
213
|
+
const version = await adapters.content.versionOf?.(params.collection, params.file);
|
|
214
|
+
return version ? { json: data, headers: { ETag: `"${version}"` } } : ok(data);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
{
|
|
219
|
+
method: "PUT",
|
|
220
|
+
path: "/api/collections/:collection/:file",
|
|
221
|
+
auth: "any",
|
|
222
|
+
handler: async ({ adapters, params, body, header }) => {
|
|
223
|
+
const bodyErr = pageBodyError(body);
|
|
224
|
+
if (bodyErr) return badRequest(bodyErr);
|
|
225
|
+
|
|
226
|
+
const ifMatch = header("if-match");
|
|
227
|
+
const expectedVersion = ifMatch ? ifMatch.replace(/^W\//, "").replace(/"/g, "") : undefined;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await adapters.content.writePage(params.collection, params.file, body, { expectedVersion });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err.status === 412) {
|
|
233
|
+
return {
|
|
234
|
+
status: 412,
|
|
235
|
+
json: {
|
|
236
|
+
error: "This entry changed since you loaded it. Reload to see the current version.",
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const version = await adapters.content.versionOf?.(params.collection, params.file);
|
|
244
|
+
return version ? { json: { ok: true }, headers: { ETag: `"${version}"` } } : ok({ ok: true });
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
method: "POST",
|
|
250
|
+
path: "/api/collections/:collection",
|
|
251
|
+
auth: "any",
|
|
252
|
+
handler: async ({ adapters, params, body }) => {
|
|
253
|
+
const bodyErr = pageBodyError(body);
|
|
254
|
+
if (bodyErr) return badRequest(bodyErr);
|
|
255
|
+
try {
|
|
256
|
+
const result = await adapters.content.createPage(params.collection, body);
|
|
257
|
+
return ok({ ok: true, file: result.file });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err.status >= 400 && err.status < 500) {
|
|
260
|
+
return { status: err.status, json: { error: err.message } };
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
method: "DELETE",
|
|
269
|
+
path: "/api/collections/:collection/:file",
|
|
270
|
+
auth: "admin",
|
|
271
|
+
handler: async ({ adapters, params }) => {
|
|
272
|
+
const okDel = await adapters.content.deletePage(params.collection, params.file);
|
|
273
|
+
if (!okDel) return notFound();
|
|
274
|
+
return ok({ ok: true });
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
{
|
|
279
|
+
method: "POST",
|
|
280
|
+
path: "/api/collections/:collection/:file/duplicate",
|
|
281
|
+
auth: "any",
|
|
282
|
+
handler: async ({ adapters, params }) => {
|
|
283
|
+
const result = await adapters.content.duplicatePage(params.collection, params.file);
|
|
284
|
+
if (!result) return notFound();
|
|
285
|
+
return ok({ ok: true, file: result.file });
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// ── Preview URL ────────────────────────────────────────────────────────
|
|
290
|
+
// Resolves the preview URL for an entry. Supports both `config.previewUrl`
|
|
291
|
+
// (a function, called server-side with the page data) and the simpler
|
|
292
|
+
// `config.previewUrlPattern` (template string with {collection}/{slug}/{lang}).
|
|
293
|
+
{
|
|
294
|
+
method: "GET",
|
|
295
|
+
path: "/api/preview-url/:collection/:file",
|
|
296
|
+
auth: "any",
|
|
297
|
+
handler: async ({ adapters, params, config }) => {
|
|
298
|
+
const data = await adapters.content.readPage(params.collection, params.file);
|
|
299
|
+
if (!data) return notFound();
|
|
300
|
+
|
|
301
|
+
let url = null;
|
|
302
|
+
if (typeof config.previewUrl === "function") {
|
|
303
|
+
try {
|
|
304
|
+
url = config.previewUrl({ collection: params.collection, data });
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return { status: 500, json: { error: `previewUrl(): ${err.message}` } };
|
|
307
|
+
}
|
|
308
|
+
} else if (config.previewUrlPattern) {
|
|
309
|
+
url = config.previewUrlPattern
|
|
310
|
+
.replace("{collection}", data.collection || params.collection)
|
|
311
|
+
.replace("{slug}", data.slug || "")
|
|
312
|
+
.replace("{lang}", data.lang || "");
|
|
313
|
+
}
|
|
314
|
+
return ok({ url });
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// ── History ────────────────────────────────────────────────────────────
|
|
319
|
+
{
|
|
320
|
+
method: "GET",
|
|
321
|
+
path: "/api/history/:collection/:file",
|
|
322
|
+
auth: "any",
|
|
323
|
+
handler: async ({ adapters, params }) => {
|
|
324
|
+
try {
|
|
325
|
+
return ok(await adapters.content.listHistory(params.collection, params.file));
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return { status: 500, json: { error: err.message } };
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
{
|
|
333
|
+
method: "POST",
|
|
334
|
+
path: "/api/history/:collection/:file/:ts/restore",
|
|
335
|
+
auth: "any",
|
|
336
|
+
handler: async ({ adapters, params }) => {
|
|
337
|
+
try {
|
|
338
|
+
const okRestore = await adapters.content.restoreHistory(
|
|
339
|
+
params.collection,
|
|
340
|
+
params.file,
|
|
341
|
+
params.ts,
|
|
342
|
+
);
|
|
343
|
+
if (!okRestore) return notFound("Revision not found");
|
|
344
|
+
return ok({ ok: true });
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return { status: 500, json: { error: err.message } };
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// ── Templates ──────────────────────────────────────────────────────────
|
|
352
|
+
{
|
|
353
|
+
method: "GET",
|
|
354
|
+
path: "/api/templates",
|
|
355
|
+
auth: "any",
|
|
356
|
+
handler: async ({ adapters }) => ok(await adapters.templates.list()),
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
{
|
|
360
|
+
method: "GET",
|
|
361
|
+
path: "/api/templates/:slug",
|
|
362
|
+
auth: "any",
|
|
363
|
+
handler: async ({ adapters, params }) => {
|
|
364
|
+
const data = await adapters.templates.get(params.slug);
|
|
365
|
+
if (!data) return notFound();
|
|
366
|
+
return ok(data);
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
{
|
|
371
|
+
method: "POST",
|
|
372
|
+
path: "/api/templates",
|
|
373
|
+
auth: "any",
|
|
374
|
+
handler: async ({ adapters, body }) => {
|
|
375
|
+
const { name, blocks } = body || {};
|
|
376
|
+
if (!name || !blocks) return badRequest("name and blocks required");
|
|
377
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
378
|
+
await adapters.templates.put(slug, { name, blocks });
|
|
379
|
+
return ok({ ok: true, slug });
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
{
|
|
384
|
+
method: "DELETE",
|
|
385
|
+
path: "/api/templates/:slug",
|
|
386
|
+
auth: "admin",
|
|
387
|
+
handler: async ({ adapters, params }) => {
|
|
388
|
+
const okDel = await adapters.templates.delete(params.slug);
|
|
389
|
+
if (!okDel) return notFound();
|
|
390
|
+
return ok({ ok: true });
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
// ── Media ──────────────────────────────────────────────────────────────
|
|
395
|
+
{
|
|
396
|
+
method: "GET",
|
|
397
|
+
path: "/api/media/folders",
|
|
398
|
+
auth: "any",
|
|
399
|
+
handler: async ({ adapters }) => {
|
|
400
|
+
const denied = mediaTokenUnavailable(adapters);
|
|
401
|
+
if (denied) return denied;
|
|
402
|
+
try {
|
|
403
|
+
if (!adapters.cdnMedia) return notFound("Media CDN is not configured");
|
|
404
|
+
return ok(await adapters.cdnMedia.listFolders());
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return mediaError(err);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
{
|
|
412
|
+
method: "GET",
|
|
413
|
+
path: "/api/media/folder/:folder",
|
|
414
|
+
auth: "any",
|
|
415
|
+
handler: async ({ adapters, params, query }) => {
|
|
416
|
+
const denied = mediaTokenUnavailable(adapters);
|
|
417
|
+
if (denied) return denied;
|
|
418
|
+
try {
|
|
419
|
+
if (!adapters.cdnMedia) return notFound("Media CDN is not configured");
|
|
420
|
+
return ok(
|
|
421
|
+
await adapters.cdnMedia.listFolder(params.folder, {
|
|
422
|
+
page: parseInt(query.page) || 1,
|
|
423
|
+
perPage: parseInt(query.per_page) || 30,
|
|
424
|
+
search: query.search || "",
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
return mediaError(err);
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
{
|
|
434
|
+
method: "POST",
|
|
435
|
+
path: "/api/media/upload",
|
|
436
|
+
auth: "any",
|
|
437
|
+
handler: async ({ adapters, body }) => {
|
|
438
|
+
const denied = mediaTokenUnavailable(adapters);
|
|
439
|
+
if (denied) return denied;
|
|
440
|
+
try {
|
|
441
|
+
if (!adapters.cdnMedia) return notFound("Media CDN is not configured");
|
|
442
|
+
return ok(await adapters.cdnMedia.upload(body));
|
|
443
|
+
} catch (err) {
|
|
444
|
+
return mediaError(err);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// ── Local assets ───────────────────────────────────────────────────────
|
|
450
|
+
// Only the Node runtime has a filesystem, so `localMedia` is absent in a
|
|
451
|
+
// Worker. Declaring the capability explicitly beats letting the route throw.
|
|
452
|
+
{
|
|
453
|
+
method: "GET",
|
|
454
|
+
path: "/api/assets",
|
|
455
|
+
auth: "any",
|
|
456
|
+
handler: async ({ adapters }) => {
|
|
457
|
+
if (!adapters.localMedia) return notFound("Local assets are not available in this runtime");
|
|
458
|
+
return ok(await adapters.localMedia.listGrouped());
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
{
|
|
463
|
+
method: "GET",
|
|
464
|
+
path: "/api/assets/:folder",
|
|
465
|
+
auth: "any",
|
|
466
|
+
handler: async ({ adapters, params }) => {
|
|
467
|
+
if (!adapters.localMedia) return notFound("Local assets are not available in this runtime");
|
|
468
|
+
return ok(await adapters.localMedia.listFolder(params.folder));
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
// ── Forms ──────────────────────────────────────────────────────────────
|
|
473
|
+
{
|
|
474
|
+
// Public form intake — the replacement for Netlify Forms on Worker
|
|
475
|
+
// deployments, and the same endpoint in local dev. Order of the gates
|
|
476
|
+
// matters: cheap and silent first (unknown form, honeypot), then limits,
|
|
477
|
+
// then the rate limiter, then the only expensive step (delivery).
|
|
478
|
+
method: "POST",
|
|
479
|
+
path: "/api/forms/:name",
|
|
480
|
+
auth: "public",
|
|
481
|
+
rawBody: true,
|
|
482
|
+
handler: async ({ adapters, config, params, request, env }) => {
|
|
483
|
+
const def = config.forms?.[params.name];
|
|
484
|
+
if (!def) return notFound("Unknown form");
|
|
485
|
+
|
|
486
|
+
const fields = await readFormFields(request);
|
|
487
|
+
if (!fields) return badRequest("Unreadable form body");
|
|
488
|
+
const limitErr = fieldsError(fields);
|
|
489
|
+
if (limitErr) return badRequest(limitErr);
|
|
490
|
+
|
|
491
|
+
// Bots fill every input. A filled honeypot gets a cheerful 200 — an
|
|
492
|
+
// error would only teach the bot which field to skip.
|
|
493
|
+
const honeypot = def.honeypot ?? "bot-field";
|
|
494
|
+
const wantsHtml = (request.headers.get("accept") ?? "").includes("text/html");
|
|
495
|
+
const success = () =>
|
|
496
|
+
wantsHtml && def.redirect ? { status: 303, redirect: def.redirect } : ok({ ok: true });
|
|
497
|
+
if (fields[honeypot]) return success();
|
|
498
|
+
|
|
499
|
+
if (def.turnstile) {
|
|
500
|
+
const secret = env(def.turnstileSecretEnv || "TURNSTILE_SECRET");
|
|
501
|
+
if (!secret) return { status: 503, json: { error: "Form verification is not configured" } };
|
|
502
|
+
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
503
|
+
method: "POST",
|
|
504
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
505
|
+
body: new URLSearchParams({
|
|
506
|
+
secret,
|
|
507
|
+
response: fields["cf-turnstile-response"] ?? "",
|
|
508
|
+
}),
|
|
509
|
+
});
|
|
510
|
+
const verdict = await check.json().catch(() => ({ success: false }));
|
|
511
|
+
if (!verdict.success) return badRequest("Verification failed — please retry");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const limiter = adapters.formLimiter;
|
|
515
|
+
if (limiter && !(await limiter.allow(`${params.name}:${clientIp(request)}`))) {
|
|
516
|
+
return { status: 429, json: { error: "Too many submissions — please wait a minute" } };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!adapters.mail?.configured) {
|
|
520
|
+
return {
|
|
521
|
+
status: 503,
|
|
522
|
+
json: { error: "Mail delivery is not configured — set config.mail and its API key" },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const subject =
|
|
527
|
+
typeof def.subject === "function"
|
|
528
|
+
? def.subject(fields)
|
|
529
|
+
: def.subject || `Yeni form gönderimi: ${params.name}`;
|
|
530
|
+
const replyTo = def.replyTo ? fields[def.replyTo] : undefined;
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await adapters.mail.send({
|
|
534
|
+
to: def.to,
|
|
535
|
+
subject,
|
|
536
|
+
text: formatMessage(fields, { skip: [honeypot] }),
|
|
537
|
+
...(replyTo ? { replyTo } : {}),
|
|
538
|
+
});
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return { status: 502, json: { error: err.message } };
|
|
541
|
+
}
|
|
542
|
+
return success();
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
// ── Publish ────────────────────────────────────────────────────────────
|
|
547
|
+
{
|
|
548
|
+
method: "POST",
|
|
549
|
+
path: "/api/publish",
|
|
550
|
+
auth: "admin",
|
|
551
|
+
handler: async ({ adapters }) => {
|
|
552
|
+
try {
|
|
553
|
+
return ok(await adapters.content.publish());
|
|
554
|
+
} catch (err) {
|
|
555
|
+
return { status: 500, json: { ok: false, message: err.message } };
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
{
|
|
561
|
+
// Publish ONE record: only this record's file is committed, so another
|
|
562
|
+
// editor's half-finished draft never rides along. Backends where saving
|
|
563
|
+
// already publishes (github) answer 501 and the UI hides the action.
|
|
564
|
+
method: "POST",
|
|
565
|
+
path: "/api/collections/:collection/:file/publish",
|
|
566
|
+
auth: "admin",
|
|
567
|
+
handler: async ({ adapters, params }) => {
|
|
568
|
+
if (!adapters.content.capabilities?.perEntryPublish) {
|
|
569
|
+
return {
|
|
570
|
+
status: 501,
|
|
571
|
+
json: { ok: false, message: "This content backend publishes on save — there is nothing to publish per entry." },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
return ok(
|
|
576
|
+
await adapters.content.publish(null, {
|
|
577
|
+
entries: [{ collection: params.collection, file: params.file }],
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
return { status: 500, json: { ok: false, message: err.message } };
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
{
|
|
587
|
+
method: "GET",
|
|
588
|
+
path: "/api/publish/status",
|
|
589
|
+
auth: "any",
|
|
590
|
+
handler: async ({ adapters }) => {
|
|
591
|
+
try {
|
|
592
|
+
return ok({
|
|
593
|
+
...(await adapters.content.pendingChanges()),
|
|
594
|
+
perEntryPublish: !!adapters.content.capabilities?.perEntryPublish,
|
|
595
|
+
});
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return { status: 500, json: { error: err.message } };
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
// ── Deploy ─────────────────────────────────────────────────────────────
|
|
603
|
+
{
|
|
604
|
+
method: "GET",
|
|
605
|
+
path: "/api/deploy/status",
|
|
606
|
+
auth: "any",
|
|
607
|
+
handler: async ({ adapters, query }) => {
|
|
608
|
+
if (!adapters.build.configured) return ok({ configured: false });
|
|
609
|
+
try {
|
|
610
|
+
return ok(
|
|
611
|
+
await adapters.build.getDeployStatus({ branch: query.branch, sha: query.sha }),
|
|
612
|
+
);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (err.upstreamStatus) {
|
|
615
|
+
return { status: 502, json: { configured: true, error: err.message } };
|
|
616
|
+
}
|
|
617
|
+
return { status: 500, json: { configured: true, error: err.message } };
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Media endpoints need the auth adapter to mint a CDN token. Ask the port
|
|
625
|
+
* whether it can, instead of calling and handling the exception as a 500.
|
|
626
|
+
*/
|
|
627
|
+
function mediaTokenUnavailable(adapters) {
|
|
628
|
+
if (adapters.auth.supports?.("mediaToken")) return null;
|
|
629
|
+
return {
|
|
630
|
+
status: 501,
|
|
631
|
+
json: {
|
|
632
|
+
error:
|
|
633
|
+
"This CMS is configured with an auth provider that cannot issue media tokens. " +
|
|
634
|
+
"Use the basic or github-oauth provider, or serve media through the CDN's own auth.",
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function mediaError(err) {
|
|
640
|
+
const status = err.upstreamStatus || 500;
|
|
641
|
+
return {
|
|
642
|
+
status: status >= 400 && status < 600 ? status : 502,
|
|
643
|
+
json: { error: err.message },
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* GitHub OAuth login/callback routes — runtime-agnostic since they only need
|
|
649
|
+
* the env-var lookup and header access provided via ctx.
|
|
650
|
+
*/
|
|
651
|
+
export const oauthRoutes = [
|
|
652
|
+
{
|
|
653
|
+
method: "GET",
|
|
654
|
+
path: "/admin/oauth/login",
|
|
655
|
+
auth: "public",
|
|
656
|
+
handler: ({ config, env, header, adapters }) => {
|
|
657
|
+
const clientId = env(config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID");
|
|
658
|
+
const proto = header("x-forwarded-proto") || "https";
|
|
659
|
+
const host = header("host");
|
|
660
|
+
const callbackUrl = `${proto}://${host}/admin/oauth/callback`;
|
|
661
|
+
const params = new URLSearchParams({
|
|
662
|
+
client_id: clientId,
|
|
663
|
+
redirect_uri: callbackUrl,
|
|
664
|
+
scope: "read:user",
|
|
665
|
+
state: adapters.auth.issueOAuthState(),
|
|
666
|
+
});
|
|
667
|
+
return { redirect: `https://github.com/login/oauth/authorize?${params}` };
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
method: "GET",
|
|
672
|
+
path: "/admin/oauth/callback",
|
|
673
|
+
auth: "public",
|
|
674
|
+
handler: async ({ config, env, header, query, adapters }) => {
|
|
675
|
+
const { code, state } = query;
|
|
676
|
+
if (!code) return { status: 400, text: "Missing code" };
|
|
677
|
+
if (!state || !adapters.auth.verifyOAuthState(state)) {
|
|
678
|
+
return { status: 400, text: "Invalid or expired OAuth state" };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const clientId = env(config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID");
|
|
682
|
+
const clientSecret = env(config.auth.githubClientSecretEnv || "GITHUB_CLIENT_SECRET");
|
|
683
|
+
const proto = header("x-forwarded-proto") || "https";
|
|
684
|
+
const host = header("host");
|
|
685
|
+
const callbackUrl = `${proto}://${host}/admin/oauth/callback`;
|
|
686
|
+
|
|
687
|
+
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
|
|
688
|
+
method: "POST",
|
|
689
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
690
|
+
body: JSON.stringify({
|
|
691
|
+
client_id: clientId,
|
|
692
|
+
client_secret: clientSecret,
|
|
693
|
+
code,
|
|
694
|
+
redirect_uri: callbackUrl,
|
|
695
|
+
}),
|
|
696
|
+
});
|
|
697
|
+
const tokenData = await tokenRes.json();
|
|
698
|
+
if (!tokenData.access_token) {
|
|
699
|
+
return {
|
|
700
|
+
status: 401,
|
|
701
|
+
text: "GitHub OAuth failed: " + (tokenData.error_description || "unknown"),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const userRes = await fetch("https://api.github.com/user", {
|
|
706
|
+
headers: {
|
|
707
|
+
Authorization: `Bearer ${tokenData.access_token}`,
|
|
708
|
+
"User-Agent": "stelstone",
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
const user = await userRes.json();
|
|
712
|
+
|
|
713
|
+
const allowedLogins = config.auth.allowedLogins || [];
|
|
714
|
+
if (allowedLogins.length > 0 && !allowedLogins.includes(user.login)) {
|
|
715
|
+
return {
|
|
716
|
+
status: 403,
|
|
717
|
+
text: `GitHub user "${user.login}" is not authorised for this CMS.`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const sessionToken = adapters.auth.issueSessionToken(
|
|
722
|
+
user.login,
|
|
723
|
+
user.name || user.login,
|
|
724
|
+
);
|
|
725
|
+
// Token travels in the URL fragment: fragments are never sent to servers
|
|
726
|
+
// and don't land in access logs or proxy logs (unlike ?token=).
|
|
727
|
+
return { redirect: `/admin/#token=${encodeURIComponent(sessionToken)}` };
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
/** All routes both runtimes mount. OAuth is conditional on auth.provider. */
|
|
733
|
+
export function allRoutes(config) {
|
|
734
|
+
const routes = [...apiRoutes];
|
|
735
|
+
if (config.auth?.provider === "github-oauth") routes.push(...oauthRoutes);
|
|
736
|
+
return routes;
|
|
737
|
+
}
|