@natilon/cms-server 0.1.2 → 0.3.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Mount runtime-agnostic routes (from routes.mjs) onto an Express app.
3
+ *
4
+ * Each handler receives a ctx object; its `ResponseSpec` return value is
5
+ * translated to res.status/json/redirect/send.
6
+ */
7
+ export function mountRoutes(app, routes, { adapters, config }) {
8
+ for (const route of routes) {
9
+ const method = route.method.toLowerCase();
10
+ app[method](route.path, async (req, res) => {
11
+ if (route.auth === "admin" && req.cmsUser && req.cmsUser.role !== "admin") {
12
+ return res.status(403).json({ error: "Admin role required" });
13
+ }
14
+ const ctx = {
15
+ params: req.params,
16
+ query: req.query,
17
+ body: req.body,
18
+ user: req.cmsUser || null,
19
+ header: (name) => req.headers[name.toLowerCase()],
20
+ env: (name) => process.env[name],
21
+ adapters,
22
+ config,
23
+ };
24
+ try {
25
+ const result = await route.handler(ctx);
26
+ writeResponse(res, result);
27
+ } catch (err) {
28
+ res.status(500).json({ error: err.message });
29
+ }
30
+ });
31
+ }
32
+ }
33
+
34
+ function writeResponse(res, result) {
35
+ if (!result) return res.status(204).end();
36
+ if (result.headers) {
37
+ for (const [k, v] of Object.entries(result.headers)) res.set(k, v);
38
+ }
39
+ if (result.redirect) return res.redirect(result.status || 302, result.redirect);
40
+ if (result.status) res.status(result.status);
41
+ if (result.json !== undefined) return res.json(result.json);
42
+ if (result.text !== undefined) return res.send(result.text);
43
+ res.end();
44
+ }
45
+
46
+ /** Build an Express middleware that returns true for any "public" route path. */
47
+ export function buildPublicAllowlist(routes) {
48
+ const exact = new Set();
49
+ for (const r of routes) if (r.auth === "public") exact.add(r.path);
50
+ return (req) => exact.has(req.path) || req.path.startsWith("/admin/oauth/");
51
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Mount runtime-agnostic routes (from routes.mjs) onto a Hono app.
3
+ *
4
+ * Usage in a Worker:
5
+ * import { Hono } from "hono";
6
+ * import { allRoutes } from "@natilon/cms-server/routes";
7
+ * import { mountHonoRoutes, honoAuthMiddleware } from "@natilon/cms-server/hono-shim";
8
+ *
9
+ * const app = new Hono();
10
+ * const routes = allRoutes(config);
11
+ * app.use("*", honoAuthMiddleware({ routes, config, buildAdapters }));
12
+ * mountHonoRoutes(app, routes, { config });
13
+ *
14
+ * `buildAdapters(env)` is supplied by the caller and must return the adapter
15
+ * bag the route handlers expect: { content, templates, cdnMedia, auth, build,
16
+ * publicConfig }.
17
+ */
18
+
19
+ export function mountHonoRoutes(app, routes, { config }) {
20
+ for (const route of routes) {
21
+ const method = route.method.toLowerCase();
22
+ app[method](route.path, async (c) => {
23
+ const adapters = c.get("adapters");
24
+ const user = c.get("cmsUser") || null;
25
+ if (route.auth === "admin" && user?.role !== "admin") {
26
+ return c.json({ error: "Admin role required" }, 403);
27
+ }
28
+ let body = null;
29
+ if (route.method === "POST" || route.method === "PUT") {
30
+ try {
31
+ body = await c.req.json();
32
+ } catch {
33
+ body = null;
34
+ }
35
+ }
36
+ const ctx = {
37
+ params: c.req.param(),
38
+ query: c.req.query(),
39
+ body,
40
+ user,
41
+ header: (name) => c.req.header(name),
42
+ env: (name) => c.env[name],
43
+ adapters,
44
+ config,
45
+ };
46
+ try {
47
+ const result = await route.handler(ctx);
48
+ return writeResponse(c, result);
49
+ } catch (err) {
50
+ return c.json({ error: err.message }, 500);
51
+ }
52
+ });
53
+ }
54
+ }
55
+
56
+ function writeResponse(c, result) {
57
+ if (!result) return new Response(null, { status: 204 });
58
+ if (result.redirect) return c.redirect(result.redirect, result.status || 302);
59
+ const status = result.status || 200;
60
+ if (result.headers) {
61
+ for (const [k, v] of Object.entries(result.headers)) c.header(k, v);
62
+ }
63
+ if (result.json !== undefined) return c.json(result.json, status);
64
+ if (result.text !== undefined) return c.text(result.text, status);
65
+ return new Response(null, { status });
66
+ }
67
+
68
+ /**
69
+ * Middleware that builds adapters per-request, enforces auth on /api/* except
70
+ * for routes declared `auth: "public"` (and any /admin/oauth/* path).
71
+ */
72
+ export function honoAuthMiddleware({ routes, config, buildAdapters, realm = "CMS Admin" }) {
73
+ const publicMatchers = compilePublicMatchers(routes);
74
+
75
+ return async (c, next) => {
76
+ const adapters = buildAdapters(c.env);
77
+ c.set("adapters", adapters);
78
+
79
+ const path = c.req.path;
80
+ const isPublic =
81
+ path.startsWith("/admin/oauth/") || publicMatchers.some((m) => m(path, c.req.method));
82
+
83
+ if (!path.startsWith("/api/") || isPublic) return next();
84
+
85
+ const cmsUser = adapters.auth.verify(c.req.header("Authorization") || "");
86
+ if (!cmsUser) {
87
+ if (config.auth?.provider === "github-oauth") {
88
+ return c.json({ error: "Authentication required", loginUrl: "/admin/oauth/login" }, 401);
89
+ }
90
+ return new Response("Authentication required", {
91
+ status: 401,
92
+ headers: { "WWW-Authenticate": `Basic realm="${realm}"` },
93
+ });
94
+ }
95
+ c.set("cmsUser", cmsUser);
96
+ return next();
97
+ };
98
+ }
99
+
100
+ function compilePublicMatchers(routes) {
101
+ return routes
102
+ .filter((r) => r.auth === "public")
103
+ .map((r) => {
104
+ const pattern = r.path.replace(/:[a-zA-Z_]+/g, "([^/]+)");
105
+ const re = new RegExp(`^${pattern}$`);
106
+ return (path, method) => method === r.method && re.test(path);
107
+ });
108
+ }
package/src/index.mjs CHANGED
@@ -2,7 +2,9 @@ export { createCmsServer, mountAdminUi, startCmsServer, startScheduler } from ".
2
2
  export { defaultPublicConfig } from "./default-public-config.mjs";
3
3
  export {
4
4
  createFsJsonContent,
5
+ createFsTemplates,
5
6
  createGitHubContent,
7
+ createGitHubTemplates,
6
8
  createLocalAssetsMedia,
7
9
  createCdnProxyMedia,
8
10
  createBasicAuth,
package/src/routes.mjs ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Runtime-agnostic API routes for the Natilon CMS.
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
+ function ok(json) {
28
+ return { json };
29
+ }
30
+
31
+ function notFound(error = "Not found") {
32
+ return { status: 404, json: { error } };
33
+ }
34
+
35
+ function badRequest(error) {
36
+ return { status: 400, json: { error } };
37
+ }
38
+
39
+ export const apiRoutes = [
40
+ // ── Config ─────────────────────────────────────────────────────────────
41
+ {
42
+ method: "GET",
43
+ path: "/api/config",
44
+ auth: "public",
45
+ handler: ({ adapters }) => ok(adapters.publicConfig()),
46
+ },
47
+
48
+ {
49
+ method: "GET",
50
+ path: "/api/me",
51
+ auth: "any",
52
+ handler: ({ user }) => ok(user || { login: "admin", role: "admin" }),
53
+ },
54
+
55
+ // ── Collections ────────────────────────────────────────────────────────
56
+ {
57
+ method: "GET",
58
+ path: "/api/collections",
59
+ auth: "any",
60
+ handler: async ({ adapters }) => ok(await adapters.content.listCollections()),
61
+ },
62
+
63
+ {
64
+ method: "GET",
65
+ path: "/api/collections/:collection",
66
+ auth: "any",
67
+ handler: async ({ adapters, config, params }) => {
68
+ const sortConfig = config.collections?.[params.collection]?.sort ?? null;
69
+ const pages = await adapters.content.listPages(params.collection, sortConfig);
70
+ if (pages === null) return notFound();
71
+ return ok(pages);
72
+ },
73
+ },
74
+
75
+ {
76
+ method: "GET",
77
+ path: "/api/collections/:collection/:file",
78
+ auth: "any",
79
+ handler: async ({ adapters, params }) => {
80
+ const data = await adapters.content.readPage(params.collection, params.file);
81
+ if (!data) return notFound();
82
+ return ok(data);
83
+ },
84
+ },
85
+
86
+ {
87
+ method: "PUT",
88
+ path: "/api/collections/:collection/:file",
89
+ auth: "any",
90
+ handler: async ({ adapters, params, body }) => {
91
+ await adapters.content.writePage(params.collection, params.file, body);
92
+ return ok({ ok: true });
93
+ },
94
+ },
95
+
96
+ {
97
+ method: "POST",
98
+ path: "/api/collections/:collection",
99
+ auth: "any",
100
+ handler: async ({ adapters, params, body }) => {
101
+ const result = await adapters.content.createPage(params.collection, body);
102
+ return ok({ ok: true, file: result.file });
103
+ },
104
+ },
105
+
106
+ {
107
+ method: "DELETE",
108
+ path: "/api/collections/:collection/:file",
109
+ auth: "admin",
110
+ handler: async ({ adapters, params }) => {
111
+ const okDel = await adapters.content.deletePage(params.collection, params.file);
112
+ if (!okDel) return notFound();
113
+ return ok({ ok: true });
114
+ },
115
+ },
116
+
117
+ {
118
+ method: "POST",
119
+ path: "/api/collections/:collection/:file/duplicate",
120
+ auth: "any",
121
+ handler: async ({ adapters, params }) => {
122
+ const result = await adapters.content.duplicatePage(params.collection, params.file);
123
+ if (!result) return notFound();
124
+ return ok({ ok: true, file: result.file });
125
+ },
126
+ },
127
+
128
+ // ── History ────────────────────────────────────────────────────────────
129
+ {
130
+ method: "GET",
131
+ path: "/api/history/:collection/:file",
132
+ auth: "any",
133
+ handler: async ({ adapters, params }) => {
134
+ try {
135
+ return ok(await adapters.content.listHistory(params.collection, params.file));
136
+ } catch (err) {
137
+ return { status: 500, json: { error: err.message } };
138
+ }
139
+ },
140
+ },
141
+
142
+ {
143
+ method: "POST",
144
+ path: "/api/history/:collection/:file/:ts/restore",
145
+ auth: "any",
146
+ handler: async ({ adapters, params }) => {
147
+ try {
148
+ const okRestore = await adapters.content.restoreHistory(
149
+ params.collection,
150
+ params.file,
151
+ params.ts,
152
+ );
153
+ if (!okRestore) return notFound("Revision not found");
154
+ return ok({ ok: true });
155
+ } catch (err) {
156
+ return { status: 500, json: { error: err.message } };
157
+ }
158
+ },
159
+ },
160
+
161
+ // ── Templates ──────────────────────────────────────────────────────────
162
+ {
163
+ method: "GET",
164
+ path: "/api/templates",
165
+ auth: "any",
166
+ handler: async ({ adapters }) => ok(await adapters.templates.list()),
167
+ },
168
+
169
+ {
170
+ method: "GET",
171
+ path: "/api/templates/:slug",
172
+ auth: "any",
173
+ handler: async ({ adapters, params }) => {
174
+ const data = await adapters.templates.get(params.slug);
175
+ if (!data) return notFound();
176
+ return ok(data);
177
+ },
178
+ },
179
+
180
+ {
181
+ method: "POST",
182
+ path: "/api/templates",
183
+ auth: "any",
184
+ handler: async ({ adapters, body }) => {
185
+ const { name, blocks } = body || {};
186
+ if (!name || !blocks) return badRequest("name and blocks required");
187
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
188
+ await adapters.templates.put(slug, { name, blocks });
189
+ return ok({ ok: true, slug });
190
+ },
191
+ },
192
+
193
+ {
194
+ method: "DELETE",
195
+ path: "/api/templates/:slug",
196
+ auth: "admin",
197
+ handler: async ({ adapters, params }) => {
198
+ const okDel = await adapters.templates.delete(params.slug);
199
+ if (!okDel) return notFound();
200
+ return ok({ ok: true });
201
+ },
202
+ },
203
+
204
+ // ── Media ──────────────────────────────────────────────────────────────
205
+ {
206
+ method: "GET",
207
+ path: "/api/media/folders",
208
+ auth: "any",
209
+ handler: async ({ adapters }) => {
210
+ try {
211
+ return ok(await adapters.cdnMedia.listFolders());
212
+ } catch (err) {
213
+ return mediaError(err);
214
+ }
215
+ },
216
+ },
217
+
218
+ {
219
+ method: "GET",
220
+ path: "/api/media/folder/:folder",
221
+ auth: "any",
222
+ handler: async ({ adapters, params, query }) => {
223
+ try {
224
+ return ok(
225
+ await adapters.cdnMedia.listFolder(params.folder, {
226
+ page: parseInt(query.page) || 1,
227
+ perPage: parseInt(query.per_page) || 30,
228
+ search: query.search || "",
229
+ }),
230
+ );
231
+ } catch (err) {
232
+ return mediaError(err);
233
+ }
234
+ },
235
+ },
236
+
237
+ {
238
+ method: "POST",
239
+ path: "/api/media/upload",
240
+ auth: "any",
241
+ handler: async ({ adapters, body }) => {
242
+ try {
243
+ return ok(await adapters.cdnMedia.upload(body));
244
+ } catch (err) {
245
+ return mediaError(err);
246
+ }
247
+ },
248
+ },
249
+
250
+ // ── Publish ────────────────────────────────────────────────────────────
251
+ {
252
+ method: "POST",
253
+ path: "/api/publish",
254
+ auth: "admin",
255
+ handler: async ({ adapters }) => {
256
+ try {
257
+ return ok(await adapters.content.publish());
258
+ } catch (err) {
259
+ return { status: 500, json: { ok: false, message: err.message } };
260
+ }
261
+ },
262
+ },
263
+
264
+ {
265
+ method: "GET",
266
+ path: "/api/publish/status",
267
+ auth: "any",
268
+ handler: async ({ adapters }) => {
269
+ try {
270
+ return ok(await adapters.content.pendingChanges());
271
+ } catch (err) {
272
+ return { status: 500, json: { error: err.message } };
273
+ }
274
+ },
275
+ },
276
+
277
+ // ── Deploy ─────────────────────────────────────────────────────────────
278
+ {
279
+ method: "GET",
280
+ path: "/api/deploy/status",
281
+ auth: "any",
282
+ handler: async ({ adapters, query }) => {
283
+ if (!adapters.build.configured) return ok({ configured: false });
284
+ try {
285
+ return ok(
286
+ await adapters.build.getDeployStatus({ branch: query.branch, sha: query.sha }),
287
+ );
288
+ } catch (err) {
289
+ if (err.upstreamStatus) {
290
+ return { status: 502, json: { configured: true, error: err.message } };
291
+ }
292
+ return { status: 500, json: { configured: true, error: err.message } };
293
+ }
294
+ },
295
+ },
296
+ ];
297
+
298
+ function mediaError(err) {
299
+ const status = err.upstreamStatus || 500;
300
+ return {
301
+ status: status >= 400 && status < 600 ? status : 502,
302
+ json: { error: err.message },
303
+ };
304
+ }
305
+
306
+ /**
307
+ * GitHub OAuth login/callback routes — runtime-agnostic since they only need
308
+ * the env-var lookup and header access provided via ctx.
309
+ */
310
+ export const oauthRoutes = [
311
+ {
312
+ method: "GET",
313
+ path: "/admin/oauth/login",
314
+ auth: "public",
315
+ handler: ({ config, env, header, adapters }) => {
316
+ const clientId = env(config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID");
317
+ const proto = header("x-forwarded-proto") || "https";
318
+ const host = header("host");
319
+ const callbackUrl = `${proto}://${host}/admin/oauth/callback`;
320
+ const params = new URLSearchParams({
321
+ client_id: clientId,
322
+ redirect_uri: callbackUrl,
323
+ scope: "read:user",
324
+ state: adapters.auth.issueOAuthState(),
325
+ });
326
+ return { redirect: `https://github.com/login/oauth/authorize?${params}` };
327
+ },
328
+ },
329
+ {
330
+ method: "GET",
331
+ path: "/admin/oauth/callback",
332
+ auth: "public",
333
+ handler: async ({ config, env, header, query, adapters }) => {
334
+ const { code, state } = query;
335
+ if (!code) return { status: 400, text: "Missing code" };
336
+ if (!state || !adapters.auth.verifyOAuthState(state)) {
337
+ return { status: 400, text: "Invalid or expired OAuth state" };
338
+ }
339
+
340
+ const clientId = env(config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID");
341
+ const clientSecret = env(config.auth.githubClientSecretEnv || "GITHUB_CLIENT_SECRET");
342
+ const proto = header("x-forwarded-proto") || "https";
343
+ const host = header("host");
344
+ const callbackUrl = `${proto}://${host}/admin/oauth/callback`;
345
+
346
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
347
+ method: "POST",
348
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
349
+ body: JSON.stringify({
350
+ client_id: clientId,
351
+ client_secret: clientSecret,
352
+ code,
353
+ redirect_uri: callbackUrl,
354
+ }),
355
+ });
356
+ const tokenData = await tokenRes.json();
357
+ if (!tokenData.access_token) {
358
+ return {
359
+ status: 401,
360
+ text: "GitHub OAuth failed: " + (tokenData.error_description || "unknown"),
361
+ };
362
+ }
363
+
364
+ const userRes = await fetch("https://api.github.com/user", {
365
+ headers: {
366
+ Authorization: `Bearer ${tokenData.access_token}`,
367
+ "User-Agent": "natilon-cms",
368
+ },
369
+ });
370
+ const user = await userRes.json();
371
+
372
+ const allowedLogins = config.auth.allowedLogins || [];
373
+ if (allowedLogins.length > 0 && !allowedLogins.includes(user.login)) {
374
+ return {
375
+ status: 403,
376
+ text: `GitHub user "${user.login}" is not authorised for this CMS.`,
377
+ };
378
+ }
379
+
380
+ const sessionToken = adapters.auth.issueSessionToken(
381
+ user.login,
382
+ user.name || user.login,
383
+ );
384
+ return { redirect: `/admin/?token=${encodeURIComponent(sessionToken)}` };
385
+ },
386
+ },
387
+ ];
388
+
389
+ /** All routes both runtimes mount. OAuth is conditional on auth.provider. */
390
+ export function allRoutes(config) {
391
+ const routes = [...apiRoutes];
392
+ if (config.auth?.provider === "github-oauth") routes.push(...oauthRoutes);
393
+ return routes;
394
+ }