@natilon/cms-server 0.1.2 → 0.5.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/src/server.mjs CHANGED
@@ -3,6 +3,8 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { resolveAdminUiDir } from "./admin-ui-path.mjs";
5
5
  import { defaultPublicConfig } from "./default-public-config.mjs";
6
+ import { allRoutes } from "./routes.mjs";
7
+ import { mountRoutes, buildPublicAllowlist } from "./express-shim.mjs";
6
8
  import {
7
9
  createFsJsonContent,
8
10
  createGitHubContent,
@@ -10,7 +12,10 @@ import {
10
12
  createCdnProxyMedia,
11
13
  createBasicAuth,
12
14
  createGitHubOAuth,
15
+ createCloudflareAccess,
13
16
  createNetlifyBuild,
17
+ createFsTemplates,
18
+ createGitHubTemplates,
14
19
  } from "./adapters/index.mjs";
15
20
 
16
21
  /**
@@ -23,8 +28,6 @@ import {
23
28
  * @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
24
29
  */
25
30
  export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn, realm = "CMS Admin" }) {
26
- // Auto-derive publicConfig when the consumer doesn't provide one.
27
- const publicConfig = publicConfigFn ?? (() => defaultPublicConfig(config));
28
31
  const content =
29
32
  config.content?.provider === "github"
30
33
  ? createGitHubContent({
@@ -59,13 +62,21 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
59
62
  jwtTtl: config.auth.jwtTtl,
60
63
  realm,
61
64
  })
62
- : createBasicAuth({
63
- user: process.env[config.auth.userEnv] || "admin",
64
- pass: process.env[config.auth.passEnv],
65
- jwtSecret: process.env[config.auth.jwtSecretEnv],
66
- jwtTtl: config.auth.jwtTtl,
67
- realm,
68
- });
65
+ : config.auth?.provider === "cloudflare-access"
66
+ ? createCloudflareAccess({
67
+ teamDomain: config.auth.teamDomain,
68
+ audience: config.auth.audience,
69
+ roles: config.auth.roles || {},
70
+ defaultRole: config.auth.defaultRole || "editor",
71
+ })
72
+ : createBasicAuth({
73
+ user: process.env[config.auth.userEnv] || "admin",
74
+ pass: process.env[config.auth.passEnv],
75
+ users: config.auth.users,
76
+ jwtSecret: process.env[config.auth.jwtSecretEnv],
77
+ jwtTtl: config.auth.jwtTtl,
78
+ realm,
79
+ });
69
80
 
70
81
  const cdnMedia = createCdnProxyMedia({
71
82
  baseUrl: config.media.cdnBase,
@@ -78,162 +89,68 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
78
89
  defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
79
90
  });
80
91
 
92
+ const templates =
93
+ config.content?.provider === "github"
94
+ ? createGitHubTemplates({
95
+ token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
96
+ owner: config.content.owner,
97
+ repo: config.content.repo,
98
+ branch: process.env.STAGING_BRANCH || config.content.branch || "main",
99
+ templatesDir: config.content.templatesDir,
100
+ commitMessage: config.content.commitMessage,
101
+ })
102
+ : createFsTemplates({ rootDir });
103
+
104
+ const adapters = {
105
+ content,
106
+ templates,
107
+ localMedia,
108
+ cdnMedia,
109
+ auth,
110
+ build,
111
+ publicConfig: () => (publicConfigFn ?? defaultPublicConfig)(config),
112
+ };
113
+
81
114
  const app = express();
82
115
  app.use(express.json({ limit: "10mb" }));
83
116
 
84
- app.use((_req, res, next) => {
85
- res.header("Access-Control-Allow-Origin", "*");
117
+ const corsOrigin = config.cors?.origin ?? "*";
118
+ if (corsOrigin === "*" && config.auth?.provider) {
119
+ console.warn(
120
+ "WARNING: CORS origin is '*' — any site can call this API in a logged-in user's browser. Set cors.origin in cms.config.mjs to lock down.",
121
+ );
122
+ }
123
+ const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
124
+ app.use((req, res, next) => {
125
+ const reqOrigin = req.headers.origin;
126
+ if (corsAllowList.includes("*")) {
127
+ res.header("Access-Control-Allow-Origin", "*");
128
+ } else if (reqOrigin && corsAllowList.includes(reqOrigin)) {
129
+ res.header("Access-Control-Allow-Origin", reqOrigin);
130
+ res.header("Vary", "Origin");
131
+ }
86
132
  res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
87
133
  res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
88
- if (_req.method === "OPTIONS") return res.sendStatus(204);
134
+ if (req.method === "OPTIONS") return res.sendStatus(204);
89
135
  next();
90
136
  });
91
137
 
92
- // GitHub OAuth routes (unauthenticated — must be mounted before the auth guard)
93
- if (auth.oauthRoutes) {
94
- app.get("/admin/oauth/login", (req, res) => auth.oauthRoutes.login(req, res));
95
- app.get("/admin/oauth/callback", (req, res) => auth.oauthRoutes.callback(req, res));
96
- }
138
+ const routes = allRoutes(config);
97
139
 
98
140
  if (auth.configured) {
141
+ const allow = buildPublicAllowlist(routes);
99
142
  app.use(
100
143
  auth.middlewareWith(
101
- (req) =>
102
- req.path.startsWith(`${localMedia.urlPrefix}/`) ||
103
- req.path === "/api/config" ||
104
- req.path.startsWith("/admin/oauth/"),
144
+ (req) => req.path.startsWith(`${localMedia.urlPrefix}/`) || allow(req),
105
145
  ),
106
146
  );
107
147
  } else {
108
148
  console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
109
149
  }
110
150
 
111
- const TEMPLATES_DIR = path.join(rootDir, ".cms-templates");
112
-
113
- function sanitizeSlug(s) {
114
- return String(s).replace(/[^a-zA-Z0-9._-]/g, "");
115
- }
116
-
117
- function requireAdmin(req, res, next) {
118
- if (req.cmsUser && req.cmsUser.role !== "admin") {
119
- return res.status(403).json({ error: "Admin role required" });
120
- }
121
- next();
122
- }
123
-
124
- app.get("/api/config", (_req, res) => {
125
- const cfg = publicConfig ? publicConfig() : {};
126
- // Always expose auth provider and previewUrlPattern to the browser
127
- if (!cfg.auth) cfg.auth = {};
128
- if (!cfg.auth.provider) cfg.auth.provider = config.auth?.provider || "basic";
129
- if (!cfg.previewUrlPattern && config.previewUrlPattern) {
130
- cfg.previewUrlPattern = config.previewUrlPattern;
131
- }
132
- res.json(cfg);
133
- });
134
-
135
- app.get("/api/me", (req, res) => {
136
- res.json(req.cmsUser || { login: "admin", role: "admin" });
137
- });
138
-
139
- app.get("/api/collections", async (_req, res) => {
140
- res.json(await content.listCollections());
141
- });
142
-
143
- app.get("/api/collections/:collection", async (req, res) => {
144
- const sortConfig = config.collections?.[req.params.collection]?.sort ?? null;
145
- const pages = await content.listPages(req.params.collection, sortConfig);
146
- if (pages === null) return res.status(404).json({ error: "Not found" });
147
- res.json(pages);
148
- });
149
-
150
- app.get("/api/collections/:collection/:file", async (req, res) => {
151
- const data = await content.readPage(req.params.collection, req.params.file);
152
- if (!data) return res.status(404).json({ error: "Not found" });
153
- res.json(data);
154
- });
155
-
156
- app.put("/api/collections/:collection/:file", async (req, res) => {
157
- await content.writePage(req.params.collection, req.params.file, req.body);
158
- res.json({ ok: true });
159
- });
160
-
161
- app.post("/api/collections/:collection", async (req, res) => {
162
- const result = await content.createPage(req.params.collection, req.body);
163
- res.json({ ok: true, file: result.file });
164
- });
165
-
166
- app.delete("/api/collections/:collection/:file", requireAdmin, async (req, res) => {
167
- const ok = await content.deletePage(req.params.collection, req.params.file);
168
- if (!ok) return res.status(404).json({ error: "Not found" });
169
- res.json({ ok: true });
170
- });
171
-
172
- app.post("/api/collections/:collection/:file/duplicate", async (req, res) => {
173
- const result = await content.duplicatePage(req.params.collection, req.params.file);
174
- if (!result) return res.status(404).json({ error: "Not found" });
175
- res.json({ ok: true, file: result.file });
176
- });
177
-
178
- app.get("/api/history/:collection/:file", async (req, res) => {
179
- try {
180
- res.json(await content.listHistory(req.params.collection, req.params.file));
181
- } catch (err) {
182
- res.status(500).json({ error: err.message });
183
- }
184
- });
185
-
186
- app.post("/api/history/:collection/:file/:ts/restore", async (req, res) => {
187
- try {
188
- const ok = await content.restoreHistory(req.params.collection, req.params.file, req.params.ts);
189
- if (!ok) return res.status(404).json({ error: "Revision not found" });
190
- res.json({ ok: true });
191
- } catch (err) {
192
- res.status(500).json({ error: err.message });
193
- }
194
- });
195
-
196
- app.get("/api/templates", (_req, res) => {
197
- if (!fs.existsSync(TEMPLATES_DIR)) return res.json([]);
198
- const files = fs.readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
199
- const list = files.map((f) => {
200
- const data = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf-8"));
201
- return { name: data.name, slug: f.replace(".json", ""), blockCount: (data.blocks || []).length };
202
- });
203
- res.json(list);
204
- });
205
-
206
- app.get("/api/templates/:slug", (req, res) => {
207
- const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
208
- if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
209
- res.json(JSON.parse(fs.readFileSync(file, "utf-8")));
210
- });
211
-
212
- app.post("/api/templates", (req, res) => {
213
- const { name, blocks } = req.body;
214
- if (!name || !blocks) return res.status(400).json({ error: "name and blocks required" });
215
- if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
216
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
217
- const file = path.join(TEMPLATES_DIR, slug + ".json");
218
- fs.writeFileSync(file, JSON.stringify({ name, blocks }, null, 2), "utf-8");
219
- res.json({ ok: true, slug });
220
- });
221
-
222
- app.delete("/api/templates/:slug", requireAdmin, (req, res) => {
223
- const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
224
- if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
225
- fs.unlinkSync(file);
226
- res.json({ ok: true });
227
- });
228
-
229
- app.get("/api/assets", async (_req, res) => {
230
- res.json(await localMedia.listGrouped());
231
- });
232
-
233
- app.get("/api/assets/:folder", async (req, res) => {
234
- res.json(await localMedia.listFolder(req.params.folder));
235
- });
151
+ mountRoutes(app, routes, { adapters, config });
236
152
 
153
+ // Local media static file serve (Express-only — Worker has no local fs).
237
154
  app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
238
155
  const sub = Array.isArray(req.params.filepath)
239
156
  ? req.params.filepath.join("/")
@@ -243,78 +160,15 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
243
160
  res.sendFile(full);
244
161
  });
245
162
 
246
- function handleMediaError(res, err) {
247
- const status = err.upstreamStatus || 500;
248
- res.status(status >= 400 && status < 600 ? status : 502).json({
249
- error: err.message,
250
- });
251
- }
252
-
253
- app.get("/api/media/folders", async (_req, res) => {
254
- try {
255
- res.json(await cdnMedia.listFolders());
256
- } catch (err) {
257
- handleMediaError(res, err);
258
- }
259
- });
260
-
261
- app.get("/api/media/folder/:folder", async (req, res) => {
262
- try {
263
- res.json(
264
- await cdnMedia.listFolder(req.params.folder, {
265
- page: parseInt(req.query.page) || 1,
266
- perPage: parseInt(req.query.per_page) || 30,
267
- search: req.query.search || "",
268
- }),
269
- );
270
- } catch (err) {
271
- handleMediaError(res, err);
272
- }
273
- });
274
-
275
- app.post("/api/media/upload", async (req, res) => {
276
- try {
277
- res.json(await cdnMedia.upload(req.body));
278
- } catch (err) {
279
- handleMediaError(res, err);
280
- }
281
- });
282
-
283
- app.post("/api/publish", requireAdmin, async (_req, res) => {
284
- try {
285
- const result = await content.publish();
286
- res.json(result);
287
- } catch (err) {
288
- res.status(500).json({ ok: false, message: err.message });
289
- }
290
- });
291
-
292
- app.get("/api/publish/status", async (_req, res) => {
293
- try {
294
- res.json(await content.pendingChanges());
295
- } catch (err) {
296
- res.status(500).json({ error: err.message });
297
- }
163
+ app.get("/api/assets", async (_req, res) => {
164
+ res.json(await localMedia.listGrouped());
298
165
  });
299
166
 
300
- app.get("/api/deploy/status", async (req, res) => {
301
- if (!build.configured) return res.json({ configured: false });
302
- try {
303
- res.json(
304
- await build.getDeployStatus({
305
- branch: req.query.branch,
306
- sha: req.query.sha,
307
- }),
308
- );
309
- } catch (err) {
310
- if (err.upstreamStatus) {
311
- return res.status(502).json({ configured: true, error: err.message });
312
- }
313
- res.status(500).json({ configured: true, error: err.message });
314
- }
167
+ app.get("/api/assets/:folder", async (req, res) => {
168
+ res.json(await localMedia.listFolder(req.params.folder));
315
169
  });
316
170
 
317
- return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
171
+ return { app, adapters: { content, templates, localMedia, cdnMedia, auth, build } };
318
172
  }
319
173
 
320
174
  /**
@@ -327,12 +181,13 @@ export function startScheduler(content, intervalMs = 60_000) {
327
181
  try {
328
182
  const due = await content.listScheduled();
329
183
  if (!due.length) return;
330
- for (const { collection, file, data } of due) {
184
+ const items = due.map(({ collection, file, data }) => {
331
185
  delete data.meta.publishAt;
332
186
  data.meta.draft = false;
333
- await content.writePage(collection, file, data);
334
- console.log(`[scheduler] Auto-published ${collection}/${file}`);
335
- }
187
+ return { collection, file, data };
188
+ });
189
+ await content.writeBatch(items, "[scheduler] Auto-publish");
190
+ console.log(`[scheduler] Auto-published ${items.length} item(s)`);
336
191
  const result = await content.publish();
337
192
  console.log("[scheduler] " + result.message);
338
193
  } catch (err) {
@@ -355,10 +210,20 @@ export async function mountAdminUi(app, adminUi) {
355
210
  const resolved =
356
211
  adminUi.mode === "auto"
357
212
  ? process.env.NODE_ENV === "production"
358
- ? { mode: "static", dir: adminUi.distDir }
359
- : { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
213
+ ? { mode: "static", dir: adminUi.distDir, previewThemeCss: adminUi.previewThemeCss }
214
+ : { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/", previewThemeCss: adminUi.previewThemeCss }
360
215
  : adminUi;
361
216
 
217
+ // Optional user CSS override for in-canvas preview styling.
218
+ // Served before the static handler so it always responds (empty if unset),
219
+ // preventing a 404 from the <link> tag in index.html.
220
+ const themePath = resolved.previewThemeCss;
221
+ app.get("/admin/preview-theme.css", (_req, res) => {
222
+ res.type("text/css");
223
+ if (themePath && fs.existsSync(themePath)) res.sendFile(path.resolve(themePath));
224
+ else res.send("/* no preview-theme.css configured */");
225
+ });
226
+
362
227
  if (resolved.mode === "static") {
363
228
  if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
364
229
  throw new Error(
@@ -395,14 +260,18 @@ export async function mountAdminUi(app, adminUi) {
395
260
  export async function startCmsServer(opts) {
396
261
  const { app, adapters } = createCmsServer(opts);
397
262
 
398
- // Auto-detect admin UI dist when not explicitly configured.
263
+ const previewThemeCss =
264
+ opts.config?.previewThemeCss
265
+ ? path.resolve(opts.rootDir || process.cwd(), opts.config.previewThemeCss)
266
+ : null;
267
+
399
268
  const adminUi = opts.adminUi ?? (() => {
400
269
  const dir = resolveAdminUiDir();
401
270
  if (!dir) {
402
271
  console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
403
272
  return null;
404
273
  }
405
- return { mode: "static", dir };
274
+ return { mode: "static", dir, previewThemeCss };
406
275
  })();
407
276
 
408
277
  await mountAdminUi(app, adminUi);