@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.
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,
@@ -11,6 +13,8 @@ import {
11
13
  createBasicAuth,
12
14
  createGitHubOAuth,
13
15
  createNetlifyBuild,
16
+ createFsTemplates,
17
+ createGitHubTemplates,
14
18
  } from "./adapters/index.mjs";
15
19
 
16
20
  /**
@@ -23,8 +27,6 @@ import {
23
27
  * @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
24
28
  */
25
29
  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
30
  const content =
29
31
  config.content?.provider === "github"
30
32
  ? createGitHubContent({
@@ -78,162 +80,68 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
78
80
  defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
79
81
  });
80
82
 
83
+ const templates =
84
+ config.content?.provider === "github"
85
+ ? createGitHubTemplates({
86
+ token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
87
+ owner: config.content.owner,
88
+ repo: config.content.repo,
89
+ branch: process.env.STAGING_BRANCH || config.content.branch || "main",
90
+ templatesDir: config.content.templatesDir,
91
+ commitMessage: config.content.commitMessage,
92
+ })
93
+ : createFsTemplates({ rootDir });
94
+
95
+ const adapters = {
96
+ content,
97
+ templates,
98
+ localMedia,
99
+ cdnMedia,
100
+ auth,
101
+ build,
102
+ publicConfig: () => (publicConfigFn ?? defaultPublicConfig)(config),
103
+ };
104
+
81
105
  const app = express();
82
106
  app.use(express.json({ limit: "10mb" }));
83
107
 
84
- app.use((_req, res, next) => {
85
- res.header("Access-Control-Allow-Origin", "*");
108
+ const corsOrigin = config.cors?.origin ?? "*";
109
+ if (corsOrigin === "*" && config.auth?.provider) {
110
+ console.warn(
111
+ "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.",
112
+ );
113
+ }
114
+ const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
115
+ app.use((req, res, next) => {
116
+ const reqOrigin = req.headers.origin;
117
+ if (corsAllowList.includes("*")) {
118
+ res.header("Access-Control-Allow-Origin", "*");
119
+ } else if (reqOrigin && corsAllowList.includes(reqOrigin)) {
120
+ res.header("Access-Control-Allow-Origin", reqOrigin);
121
+ res.header("Vary", "Origin");
122
+ }
86
123
  res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
87
124
  res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
88
- if (_req.method === "OPTIONS") return res.sendStatus(204);
125
+ if (req.method === "OPTIONS") return res.sendStatus(204);
89
126
  next();
90
127
  });
91
128
 
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
- }
129
+ const routes = allRoutes(config);
97
130
 
98
131
  if (auth.configured) {
132
+ const allow = buildPublicAllowlist(routes);
99
133
  app.use(
100
134
  auth.middlewareWith(
101
- (req) =>
102
- req.path.startsWith(`${localMedia.urlPrefix}/`) ||
103
- req.path === "/api/config" ||
104
- req.path.startsWith("/admin/oauth/"),
135
+ (req) => req.path.startsWith(`${localMedia.urlPrefix}/`) || allow(req),
105
136
  ),
106
137
  );
107
138
  } else {
108
139
  console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
109
140
  }
110
141
 
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
- });
142
+ mountRoutes(app, routes, { adapters, config });
236
143
 
144
+ // Local media static file serve (Express-only — Worker has no local fs).
237
145
  app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
238
146
  const sub = Array.isArray(req.params.filepath)
239
147
  ? req.params.filepath.join("/")
@@ -243,78 +151,15 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
243
151
  res.sendFile(full);
244
152
  });
245
153
 
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
- }
154
+ app.get("/api/assets", async (_req, res) => {
155
+ res.json(await localMedia.listGrouped());
298
156
  });
299
157
 
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
- }
158
+ app.get("/api/assets/:folder", async (req, res) => {
159
+ res.json(await localMedia.listFolder(req.params.folder));
315
160
  });
316
161
 
317
- return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
162
+ return { app, adapters: { content, templates, localMedia, cdnMedia, auth, build } };
318
163
  }
319
164
 
320
165
  /**
@@ -327,12 +172,13 @@ export function startScheduler(content, intervalMs = 60_000) {
327
172
  try {
328
173
  const due = await content.listScheduled();
329
174
  if (!due.length) return;
330
- for (const { collection, file, data } of due) {
175
+ const items = due.map(({ collection, file, data }) => {
331
176
  delete data.meta.publishAt;
332
177
  data.meta.draft = false;
333
- await content.writePage(collection, file, data);
334
- console.log(`[scheduler] Auto-published ${collection}/${file}`);
335
- }
178
+ return { collection, file, data };
179
+ });
180
+ await content.writeBatch(items, "[scheduler] Auto-publish");
181
+ console.log(`[scheduler] Auto-published ${items.length} item(s)`);
336
182
  const result = await content.publish();
337
183
  console.log("[scheduler] " + result.message);
338
184
  } catch (err) {
@@ -355,10 +201,20 @@ export async function mountAdminUi(app, adminUi) {
355
201
  const resolved =
356
202
  adminUi.mode === "auto"
357
203
  ? process.env.NODE_ENV === "production"
358
- ? { mode: "static", dir: adminUi.distDir }
359
- : { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
204
+ ? { mode: "static", dir: adminUi.distDir, previewThemeCss: adminUi.previewThemeCss }
205
+ : { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/", previewThemeCss: adminUi.previewThemeCss }
360
206
  : adminUi;
361
207
 
208
+ // Optional user CSS override for in-canvas preview styling.
209
+ // Served before the static handler so it always responds (empty if unset),
210
+ // preventing a 404 from the <link> tag in index.html.
211
+ const themePath = resolved.previewThemeCss;
212
+ app.get("/admin/preview-theme.css", (_req, res) => {
213
+ res.type("text/css");
214
+ if (themePath && fs.existsSync(themePath)) res.sendFile(path.resolve(themePath));
215
+ else res.send("/* no preview-theme.css configured */");
216
+ });
217
+
362
218
  if (resolved.mode === "static") {
363
219
  if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
364
220
  throw new Error(
@@ -395,14 +251,18 @@ export async function mountAdminUi(app, adminUi) {
395
251
  export async function startCmsServer(opts) {
396
252
  const { app, adapters } = createCmsServer(opts);
397
253
 
398
- // Auto-detect admin UI dist when not explicitly configured.
254
+ const previewThemeCss =
255
+ opts.config?.previewThemeCss
256
+ ? path.resolve(opts.rootDir || process.cwd(), opts.config.previewThemeCss)
257
+ : null;
258
+
399
259
  const adminUi = opts.adminUi ?? (() => {
400
260
  const dir = resolveAdminUiDir();
401
261
  if (!dir) {
402
262
  console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
403
263
  return null;
404
264
  }
405
- return { mode: "static", dir };
265
+ return { mode: "static", dir, previewThemeCss };
406
266
  })();
407
267
 
408
268
  await mountAdminUi(app, adminUi);