@natilon/cms-server 0.1.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,78 @@
1
+ /**
2
+ * Adapter interface contracts for the natilon CMS core.
3
+ *
4
+ * These are JSDoc typedefs only — no runtime code. Concrete implementations
5
+ * (fs-json-content, local-assets-media, basic-auth, build-netlify) export
6
+ * factory functions that return objects matching these shapes.
7
+ *
8
+ * The admin server depends only on these interfaces, so swapping a backend
9
+ * (e.g. GitHub Contents API instead of local fs, S3 instead of local
10
+ * assets, OAuth instead of basic-auth) is a one-line config change.
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} PageSummary
15
+ * @property {string} id
16
+ * @property {string} slug
17
+ * @property {string} lang
18
+ * @property {string} collection
19
+ * @property {string} title
20
+ * @property {string} file
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} CollectionSummary
25
+ * @property {string} name
26
+ * @property {number} count
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} PublishResult
31
+ * @property {boolean} ok
32
+ * @property {string} message
33
+ * @property {string} [sha]
34
+ * @property {string} [shortSha]
35
+ * @property {string} [branch]
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} PendingChanges
40
+ * @property {boolean} hasChanges
41
+ * @property {number} changedFiles
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} ContentAdapter
46
+ * @property {() => Promise<CollectionSummary[]>} listCollections
47
+ * @property {(collection: string) => Promise<PageSummary[]|null>} listPages
48
+ * @property {(collection: string, file: string) => Promise<object|null>} readPage
49
+ * @property {(collection: string, file: string, data: object) => Promise<void>} writePage
50
+ * @property {(collection: string, data: object) => Promise<{file: string}>} createPage
51
+ * @property {(collection: string, file: string) => Promise<boolean>} deletePage
52
+ * @property {(collection: string, file: string) => Promise<{file: string}|null>} duplicatePage
53
+ * @property {() => Promise<PendingChanges>} pendingChanges
54
+ * @property {() => Promise<PublishResult>} publish
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} MediaAdapter
59
+ * @property {() => Promise<Record<string, string[]>>} listGrouped
60
+ * @property {(folder: string) => Promise<string[]>} listFolder
61
+ * @property {(relPath: string) => string|null} resolveLocalPath
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} AuthAdapter
66
+ * @property {boolean} configured
67
+ * @property {import('express').RequestHandler} middleware
68
+ * @property {(allowlist: ((req: import('express').Request) => boolean)|null) => import('express').RequestHandler} middlewareWith
69
+ * @property {() => string} issueMediaToken
70
+ */
71
+
72
+ /**
73
+ * @typedef {Object} BuildAdapter
74
+ * @property {boolean} configured
75
+ * @property {(opts?: {branch?: string, sha?: string}) => Promise<object>} getDeployStatus
76
+ */
77
+
78
+ export {};
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from "url";
2
+ import path from "path";
3
+ import fs from "fs";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * Resolve the absolute path to the built admin-ui dist directory.
9
+ *
10
+ * In the monorepo the layout is:
11
+ * packages/cms-server/src/ ← this file
12
+ * packages/admin-ui/dist/ ← bundled SPA
13
+ *
14
+ * Returns null when the dist hasn't been built yet (development without a
15
+ * prior `npm run build:admin-ui`). The server will throw a helpful error
16
+ * via mountAdminUi rather than crashing on a missing index.html.
17
+ */
18
+ export function resolveAdminUiDir() {
19
+ const candidate = path.resolve(__dirname, "../../admin-ui/dist");
20
+ return fs.existsSync(path.join(candidate, "index.html")) ? candidate : null;
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Derive a safe browser-facing config from the full cms.config object.
3
+ *
4
+ * Never exposes: auth credentials env var names, build tokens, git config,
5
+ * GitHub OAuth secrets, content adapter internals.
6
+ *
7
+ * Used automatically by createCmsServer when the consumer does not supply
8
+ * their own publicConfig function.
9
+ *
10
+ * @param {Object} config Full cms.config object
11
+ * @returns {Object} Safe subset for the browser
12
+ */
13
+ export function defaultPublicConfig(config) {
14
+ return {
15
+ mountPath: config.mountPath,
16
+ locales: config.locales,
17
+ defaultLocale: config.defaultLocale,
18
+ previewUrlPattern: config.previewUrlPattern,
19
+ media: config.media,
20
+ collections: config.collections,
21
+ blocks: config.blocks,
22
+ };
23
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ export { createCmsServer, mountAdminUi, startCmsServer, startScheduler } from "./server.mjs";
2
+ export { defaultPublicConfig } from "./default-public-config.mjs";
3
+ export {
4
+ createFsJsonContent,
5
+ createGitHubContent,
6
+ createLocalAssetsMedia,
7
+ createCdnProxyMedia,
8
+ createBasicAuth,
9
+ createGitHubOAuth,
10
+ createNetlifyBuild,
11
+ createMediaUrl,
12
+ } from "./adapters/index.mjs";
package/src/server.mjs ADDED
@@ -0,0 +1,417 @@
1
+ import express from "express";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { resolveAdminUiDir } from "./admin-ui-path.mjs";
5
+ import { defaultPublicConfig } from "./default-public-config.mjs";
6
+ import {
7
+ createFsJsonContent,
8
+ createGitHubContent,
9
+ createLocalAssetsMedia,
10
+ createCdnProxyMedia,
11
+ createBasicAuth,
12
+ createGitHubOAuth,
13
+ createNetlifyBuild,
14
+ } from "./adapters/index.mjs";
15
+
16
+ /**
17
+ * Build a configured Express app for the CMS (no listen).
18
+ *
19
+ * @param {Object} opts
20
+ * @param {Object} opts.config cms.config object
21
+ * @param {string} opts.rootDir absolute path to consumer project root
22
+ * @param {() => Object} [opts.publicConfig] returns sanitized config for the browser
23
+ * @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
24
+ */
25
+ 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
+ const content =
29
+ config.content?.provider === "github"
30
+ ? createGitHubContent({
31
+ token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
32
+ owner: config.content.owner,
33
+ repo: config.content.repo,
34
+ branch: process.env.STAGING_BRANCH || config.content.branch || "main",
35
+ pagesDir: config.content.pagesDir,
36
+ commitMessage: config.content.commitMessage,
37
+ })
38
+ : createFsJsonContent({
39
+ rootDir,
40
+ pagesDir: config.content.pagesDir,
41
+ publishBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
42
+ publishPaths: config.content.publishPaths,
43
+ commitMessage: config.content.commitMessage,
44
+ });
45
+
46
+ const localMedia = createLocalAssetsMedia({
47
+ rootDir,
48
+ assetsDir: config.content.assetsDir,
49
+ });
50
+
51
+ const auth =
52
+ config.auth?.provider === "github-oauth"
53
+ ? createGitHubOAuth({
54
+ clientId: process.env[config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID"],
55
+ clientSecret: process.env[config.auth.githubClientSecretEnv || "GITHUB_CLIENT_SECRET"],
56
+ allowedLogins: config.auth.allowedLogins || [],
57
+ roles: config.auth.roles || {},
58
+ jwtSecret: process.env[config.auth.jwtSecretEnv],
59
+ jwtTtl: config.auth.jwtTtl,
60
+ realm,
61
+ })
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
+ });
69
+
70
+ const cdnMedia = createCdnProxyMedia({
71
+ baseUrl: config.media.cdnBase,
72
+ getToken: () => auth.issueMediaToken(config.media.tenantId),
73
+ });
74
+
75
+ const build = createNetlifyBuild({
76
+ token: process.env[config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"],
77
+ siteId: process.env[config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"],
78
+ defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
79
+ });
80
+
81
+ const app = express();
82
+ app.use(express.json({ limit: "10mb" }));
83
+
84
+ app.use((_req, res, next) => {
85
+ res.header("Access-Control-Allow-Origin", "*");
86
+ res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
87
+ res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
88
+ if (_req.method === "OPTIONS") return res.sendStatus(204);
89
+ next();
90
+ });
91
+
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
+ }
97
+
98
+ if (auth.configured) {
99
+ app.use(
100
+ auth.middlewareWith(
101
+ (req) =>
102
+ req.path.startsWith(`${localMedia.urlPrefix}/`) ||
103
+ req.path === "/api/config" ||
104
+ req.path.startsWith("/admin/oauth/"),
105
+ ),
106
+ );
107
+ } else {
108
+ console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
109
+ }
110
+
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
+ });
236
+
237
+ app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
238
+ const sub = Array.isArray(req.params.filepath)
239
+ ? req.params.filepath.join("/")
240
+ : req.params.filepath;
241
+ const full = localMedia.resolveLocalPath(sub);
242
+ if (!full) return res.status(404).send("Not found");
243
+ res.sendFile(full);
244
+ });
245
+
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
+ }
298
+ });
299
+
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
+ }
315
+ });
316
+
317
+ return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
318
+ }
319
+
320
+ /**
321
+ * Start a background scheduler that auto-publishes entries whose
322
+ * `meta.publishAt` timestamp has passed. Runs every 60 seconds.
323
+ * Returns a stop function.
324
+ */
325
+ export function startScheduler(content, intervalMs = 60_000) {
326
+ async function run() {
327
+ try {
328
+ const due = await content.listScheduled();
329
+ if (!due.length) return;
330
+ for (const { collection, file, data } of due) {
331
+ delete data.meta.publishAt;
332
+ data.meta.draft = false;
333
+ await content.writePage(collection, file, data);
334
+ console.log(`[scheduler] Auto-published ${collection}/${file}`);
335
+ }
336
+ const result = await content.publish();
337
+ console.log("[scheduler] " + result.message);
338
+ } catch (err) {
339
+ console.error("[scheduler] Error:", err.message);
340
+ }
341
+ }
342
+ const timer = setInterval(run, intervalMs);
343
+ return () => clearInterval(timer);
344
+ }
345
+
346
+ /**
347
+ * Mount the admin UI under /admin. Three modes:
348
+ * { mode: "static", dir } serve a prebuilt SPA bundle
349
+ * { mode: "vite-dev", root, base="/admin/" } use Vite middleware (HMR)
350
+ * { mode: "auto", distDir, sourceDir } vite-dev when NODE_ENV !== "production"
351
+ */
352
+ export async function mountAdminUi(app, adminUi) {
353
+ if (!adminUi) return;
354
+
355
+ const resolved =
356
+ adminUi.mode === "auto"
357
+ ? process.env.NODE_ENV === "production"
358
+ ? { mode: "static", dir: adminUi.distDir }
359
+ : { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
360
+ : adminUi;
361
+
362
+ if (resolved.mode === "static") {
363
+ if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
364
+ throw new Error(
365
+ `admin-ui not built at ${resolved.dir}. Run the admin-ui build before starting the server in production.`,
366
+ );
367
+ }
368
+ app.use("/admin", express.static(resolved.dir));
369
+ app.get("/admin", (_req, res) => {
370
+ res.sendFile(path.join(resolved.dir, "index.html"));
371
+ });
372
+ return;
373
+ }
374
+
375
+ if (resolved.mode === "vite-dev") {
376
+ const { createServer: createViteServer } = await import("vite");
377
+ const vite = await createViteServer({
378
+ root: resolved.root,
379
+ base: resolved.base || "/admin/",
380
+ server: { middlewareMode: true },
381
+ appType: "spa",
382
+ });
383
+ app.use("/admin", vite.middlewares);
384
+ console.log("Admin UI: Vite dev middleware (HMR enabled)");
385
+ return;
386
+ }
387
+
388
+ throw new Error(`Unknown adminUi mode: ${resolved.mode}`);
389
+ }
390
+
391
+ /**
392
+ * Convenience: build the app, mount the admin UI, listen on a port.
393
+ * Returns { server, app, adapters }.
394
+ */
395
+ export async function startCmsServer(opts) {
396
+ const { app, adapters } = createCmsServer(opts);
397
+
398
+ // Auto-detect admin UI dist when not explicitly configured.
399
+ const adminUi = opts.adminUi ?? (() => {
400
+ const dir = resolveAdminUiDir();
401
+ if (!dir) {
402
+ console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
403
+ return null;
404
+ }
405
+ return { mode: "static", dir };
406
+ })();
407
+
408
+ await mountAdminUi(app, adminUi);
409
+ const stopScheduler = startScheduler(adapters.content);
410
+ const port = opts.port || process.env.ADMIN_PORT || 4000;
411
+ return await new Promise((resolve) => {
412
+ const server = app.listen(port, () => {
413
+ console.log(`Admin server running at http://localhost:${port}/admin`);
414
+ resolve({ server, app, adapters, stopScheduler });
415
+ });
416
+ });
417
+ }