@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natilon/cms-server",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,19 +19,23 @@
19
19
  "bin": {
20
20
  "natilon-cms": "./bin/natilon-cms.mjs"
21
21
  },
22
+ "scripts": {
23
+ "test": "node --test test/"
24
+ },
22
25
  "exports": {
23
26
  ".": "./src/index.mjs",
24
27
  "./media-url": "./src/adapters/media-url.mjs",
25
28
  "./adapters": "./src/adapters/index.mjs",
26
- "./public-config": "./src/default-public-config.mjs"
29
+ "./public-config": "./src/default-public-config.mjs",
30
+ "./routes": "./src/routes.mjs",
31
+ "./hono-shim": "./src/hono-shim.mjs"
27
32
  },
28
33
  "files": [
29
34
  "src",
30
35
  "bin"
31
36
  ],
32
37
  "dependencies": {
33
- "@natilon/admin-ui": "^0.1.1",
34
- "depd": "^2.0.0",
38
+ "@natilon/admin-ui": "^0.2.0",
35
39
  "express": "^4.21.0"
36
40
  },
37
41
  "peerDependencies": {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared helpers used across content and template adapters.
3
+ */
4
+
5
+ /**
6
+ * Strips all characters outside [a-zA-Z0-9._-].
7
+ * Used to sanitize path segments coming from config (collection names, etc.).
8
+ */
9
+ export function sanitize(p) {
10
+ return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
11
+ }
12
+
13
+ /**
14
+ * Validates a filename (e.g. "en-hello.json") from a route param.
15
+ * Returns the input unchanged if it is safe, or null if it should be rejected.
16
+ *
17
+ * Rules:
18
+ * - Must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
19
+ * - Must not be "." or ".." or consist entirely of dots
20
+ */
21
+ export function safeFileName(name) {
22
+ if (typeof name !== "string") return null;
23
+ if (name === "." || name === ".." || /^\.+$/.test(name)) return null;
24
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return null;
25
+ return name;
26
+ }
27
+
28
+ /**
29
+ * Sorts a pages array in-place and returns it.
30
+ *
31
+ * When sortConfig is provided, sorts by `data.meta?.[sortConfig.field]`.
32
+ * Missing values are always placed at the end, regardless of direction.
33
+ * When sortConfig is null/undefined, sorts by title (localeCompare).
34
+ *
35
+ * @param {Array} pages Each element must have `.meta` and `.title`.
36
+ * @param {Object|null} sortConfig { field: string, direction: "asc"|"desc" }
37
+ */
38
+ export function sortPages(pages, sortConfig) {
39
+ if (sortConfig) {
40
+ const dir = sortConfig.direction === "desc" ? -1 : 1;
41
+ pages.sort((a, b) => {
42
+ const av = a.meta?.[sortConfig.field];
43
+ const bv = b.meta?.[sortConfig.field];
44
+ const aMissing = av === undefined || av === null;
45
+ const bMissing = bv === undefined || bv === null;
46
+ if (aMissing && bMissing) return 0;
47
+ if (aMissing) return 1; // missing always last
48
+ if (bMissing) return -1; // missing always last
49
+ if (av < bv) return -1 * dir;
50
+ if (av > bv) return 1 * dir;
51
+ return 0;
52
+ });
53
+ } else {
54
+ pages.sort((a, b) => a.title.localeCompare(b.title));
55
+ }
56
+ return pages;
57
+ }
58
+
59
+ /**
60
+ * Recursively regenerates block IDs.
61
+ * Children columns are arrays of block arrays (columns), so we recurse into each.
62
+ */
63
+ export function reid(blocks) {
64
+ if (!Array.isArray(blocks)) return blocks;
65
+ return blocks.map((b) => {
66
+ const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
67
+ if (Array.isArray(b.children)) {
68
+ next.children = b.children.map((col) => reid(col));
69
+ }
70
+ return next;
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Builds a duplicate page payload from a source page object.
76
+ * Returns { data, fileName }.
77
+ */
78
+ export function buildDuplicateData(src) {
79
+ const lang = src.lang || "en";
80
+ const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
81
+ const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
82
+ const data = {
83
+ ...src,
84
+ id: `${lang}/${newSlug}`,
85
+ slug: newSlug,
86
+ meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
87
+ blocks: reid(src.blocks || []),
88
+ };
89
+ const fileName = `${sanitize(lang)}-${sanitize(newSlug)}.json`;
90
+ return { data, fileName };
91
+ }
92
+
93
+ /**
94
+ * Scans all collections in `adapter` and returns entries whose
95
+ * `meta.publishAt` is in the past.
96
+ *
97
+ * @param {import('./types.mjs').ContentAdapter} adapter
98
+ * @returns {Promise<Array<{collection: string, file: string, data: object}>>}
99
+ */
100
+ export async function listScheduledDue(adapter) {
101
+ const collections = await adapter.listCollections();
102
+ const now = new Date();
103
+ const due = [];
104
+ for (const { name } of collections) {
105
+ const pages = await adapter.listPages(name);
106
+ for (const page of pages || []) {
107
+ if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
108
+ const data = await adapter.readPage(name, page.file);
109
+ if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
110
+ }
111
+ }
112
+ }
113
+ return due;
114
+ }
115
+
116
+ /**
117
+ * Produces a commit message string.
118
+ *
119
+ * @param {((ts: string) => string) | undefined} commitMessage
120
+ * @param {string} [fallback="Content updated"]
121
+ */
122
+ export function commitMsg(commitMessage, fallback = "Content updated") {
123
+ const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
124
+ return commitMessage ? commitMessage(ts) : `${fallback} ${ts}`;
125
+ }
@@ -11,6 +11,13 @@ import crypto from "crypto";
11
11
  * @param {string} [opts.realm="Admin"]
12
12
  * @returns {import('./types.mjs').AuthAdapter}
13
13
  */
14
+ function safeEq(a, b) {
15
+ const bufA = Buffer.from(a || "", "utf8");
16
+ const bufB = Buffer.from(b || "", "utf8");
17
+ if (bufA.length !== bufB.length) return false;
18
+ return crypto.timingSafeEqual(bufA, bufB);
19
+ }
20
+
14
21
  export function createBasicAuth({
15
22
  user,
16
23
  pass,
@@ -51,7 +58,7 @@ export function createBasicAuth({
51
58
  const [u, p] = Buffer.from(auth.slice(6), "base64")
52
59
  .toString()
53
60
  .split(":");
54
- if (u === user && p === pass) {
61
+ if (safeEq(u, user) && safeEq(p, pass)) {
55
62
  req.cmsUser = { login: user, role: "admin" };
56
63
  return next();
57
64
  }
@@ -67,7 +74,7 @@ export function createBasicAuth({
67
74
  const colon = decoded.indexOf(":");
68
75
  const u = decoded.slice(0, colon);
69
76
  const p = decoded.slice(colon + 1);
70
- if (u === user && p === pass) return { login: user, role: "admin" };
77
+ if (safeEq(u, user) && safeEq(p, pass)) return { login: user, role: "admin" };
71
78
  return null;
72
79
  }
73
80
 
@@ -1,3 +1,5 @@
1
+ import { sanitize } from "./_shared.mjs";
2
+
1
3
  /**
2
4
  * CDN-backed media adapter that proxies requests to a remote media
3
5
  * service expecting Bearer-token auth (e.g. the natilon media-cdn lambda).
@@ -20,13 +22,10 @@
20
22
  * upload: (payload: object) => Promise<object>,
21
23
  * }}
22
24
  */
25
+
23
26
  export function createCdnProxyMedia({ baseUrl, getToken }) {
24
27
  const ROOT = baseUrl.replace(/\/+$/, "");
25
28
 
26
- function sanitize(folder) {
27
- return String(folder).replace(/[^a-zA-Z0-9._-]/g, "");
28
- }
29
-
30
29
  async function call(path, opts = {}) {
31
30
  const r = await fetch(ROOT + path, {
32
31
  ...opts,
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { execSync } from "child_process";
4
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue } from "./_shared.mjs";
4
5
 
5
6
  const HISTORY_KEEP = 50; // max revisions kept per file
6
7
 
@@ -50,10 +51,6 @@ export function createFsJsonContent({
50
51
  return execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" }).trim();
51
52
  }
52
53
 
53
- function sanitize(p) {
54
- return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
55
- }
56
-
57
54
  function pagePath(collection, file) {
58
55
  return path.join(PAGES_DIR, sanitize(collection), sanitize(file));
59
56
  }
@@ -86,24 +83,13 @@ export function createFsJsonContent({
86
83
  title: data.meta?.title || data.meta?.name || file,
87
84
  file,
88
85
  meta: data.meta || {},
89
- _sort: sortConfig ? (data.meta?.[sortConfig.field] ?? Infinity) : null,
90
86
  };
91
87
  });
92
- if (sortConfig) {
93
- const dir = sortConfig.direction === "desc" ? -1 : 1;
94
- pages.sort((a, b) => {
95
- if (a._sort < b._sort) return -1 * dir;
96
- if (a._sort > b._sort) return 1 * dir;
97
- return 0;
98
- });
99
- } else {
100
- pages.sort((a, b) => a.title.localeCompare(b.title));
101
- }
102
- pages.forEach((p) => delete p._sort);
103
- return pages;
88
+ return sortPages(pages, sortConfig);
104
89
  },
105
90
 
106
91
  async readPage(collection, file) {
92
+ if (!safeFileName(file)) return null;
107
93
  const filePath = pagePath(collection, file);
108
94
  if (!fs.existsSync(filePath)) return null;
109
95
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -128,6 +114,7 @@ export function createFsJsonContent({
128
114
  },
129
115
 
130
116
  async deletePage(collection, file) {
117
+ if (!safeFileName(file)) return null;
131
118
  const filePath = pagePath(collection, file);
132
119
  if (!fs.existsSync(filePath)) return false;
133
120
  fs.unlinkSync(filePath);
@@ -135,36 +122,12 @@ export function createFsJsonContent({
135
122
  },
136
123
 
137
124
  async duplicatePage(collection, file) {
125
+ if (!safeFileName(file)) return null;
138
126
  const filePath = pagePath(collection, file);
139
127
  if (!fs.existsSync(filePath)) return null;
140
128
  const src = JSON.parse(fs.readFileSync(filePath, "utf-8"));
141
129
 
142
- const lang = src.lang || "en";
143
- const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
144
- const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
145
-
146
- // Regenerate block IDs to avoid duplicates within the document if the
147
- // same block is later edited in both copies.
148
- function reid(blocks) {
149
- if (!Array.isArray(blocks)) return blocks;
150
- return blocks.map((b) => {
151
- const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
152
- if (Array.isArray(b.children)) {
153
- next.children = b.children.map((col) => reid(col));
154
- }
155
- return next;
156
- });
157
- }
158
-
159
- const data = {
160
- ...src,
161
- id: `${lang}/${newSlug}`,
162
- slug: newSlug,
163
- meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
164
- blocks: reid(src.blocks || []),
165
- };
166
-
167
- const fileName = `${sanitize(lang)}-${sanitize(newSlug)}.json`;
130
+ const { data, fileName } = buildDuplicateData(src);
168
131
  const dest = pagePath(collection, fileName);
169
132
  const dir = path.dirname(dest);
170
133
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -203,6 +166,7 @@ export function createFsJsonContent({
203
166
  },
204
167
 
205
168
  async listHistory(collection, file) {
169
+ if (!safeFileName(file)) return null;
206
170
  const dir = historyDir(collection, file);
207
171
  if (!fs.existsSync(dir)) return [];
208
172
  return fs
@@ -211,16 +175,13 @@ export function createFsJsonContent({
211
175
  .sort()
212
176
  .reverse()
213
177
  .map((f) => {
214
- const ts = f.replace(".json", "").replace(/-(\d{2})-(\d{3})$/, ".$1Z").replace(/-/g, (m, o, s) => {
215
- // Reconstruct ISO: YYYY-MM-DDTHH-MM-SS-mmmZ → YYYY-MM-DDTHH:MM:SS.mmmZ
216
- return s.slice(0, o).match(/T/) ? ":" : m;
217
- });
218
178
  const stat = fs.statSync(path.join(dir, f));
219
179
  return { ts: f.replace(".json", ""), size: stat.size };
220
180
  });
221
181
  },
222
182
 
223
183
  async restoreHistory(collection, file, ts) {
184
+ if (!safeFileName(file)) return null;
224
185
  const src = path.join(historyDir(collection, file), sanitize(ts) + ".json");
225
186
  if (!fs.existsSync(src)) return false;
226
187
  const data = JSON.parse(fs.readFileSync(src, "utf-8"));
@@ -229,20 +190,16 @@ export function createFsJsonContent({
229
190
  return true;
230
191
  },
231
192
 
232
- async listScheduled() {
233
- const collections = await this.listCollections();
234
- const now = new Date();
235
- const due = [];
236
- for (const { name } of collections) {
237
- const pages = await this.listPages(name);
238
- for (const page of pages || []) {
239
- if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
240
- const data = await this.readPage(name, page.file);
241
- if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
242
- }
243
- }
193
+ async writeBatch(items, _message) {
194
+ if (!items.length) return { ok: true, commitCount: 0 };
195
+ for (const { collection, file, data } of items) {
196
+ await this.writePage(collection, file, data);
244
197
  }
245
- return due;
198
+ return { ok: true, commitCount: items.length };
199
+ },
200
+
201
+ async listScheduled() {
202
+ return listScheduledDue(this);
246
203
  },
247
204
  };
248
205
  }
@@ -0,0 +1,57 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { sanitize, safeFileName } from "./_shared.mjs";
4
+
5
+ /**
6
+ * Filesystem-backed block-template adapter. Templates are stored as JSON files
7
+ * under `<rootDir>/.cms-templates/<slug>.json`.
8
+ *
9
+ * @param {Object} opts
10
+ * @param {string} opts.rootDir
11
+ * @returns {import('./types.mjs').TemplatesAdapter}
12
+ */
13
+ export function createFsTemplates({ rootDir }) {
14
+ const DIR = path.join(rootDir, ".cms-templates");
15
+
16
+ return {
17
+ async list() {
18
+ if (!fs.existsSync(DIR)) return [];
19
+ return fs
20
+ .readdirSync(DIR)
21
+ .filter((f) => f.endsWith(".json"))
22
+ .map((f) => {
23
+ const data = JSON.parse(fs.readFileSync(path.join(DIR, f), "utf-8"));
24
+ return {
25
+ name: data.name,
26
+ slug: f.replace(".json", ""),
27
+ blockCount: (data.blocks || []).length,
28
+ };
29
+ });
30
+ },
31
+
32
+ async get(slug) {
33
+ if (!safeFileName(slug)) return null;
34
+ const file = path.join(DIR, sanitize(slug) + ".json");
35
+ if (!fs.existsSync(file)) return null;
36
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
37
+ },
38
+
39
+ async put(slug, data) {
40
+ if (!safeFileName(slug)) return null;
41
+ if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true });
42
+ fs.writeFileSync(
43
+ path.join(DIR, sanitize(slug) + ".json"),
44
+ JSON.stringify(data, null, 2),
45
+ "utf-8",
46
+ );
47
+ },
48
+
49
+ async delete(slug) {
50
+ if (!safeFileName(slug)) return null;
51
+ const file = path.join(DIR, sanitize(slug) + ".json");
52
+ if (!fs.existsSync(file)) return false;
53
+ fs.unlinkSync(file);
54
+ return true;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared helpers for adapters backed by the GitHub Contents API.
3
+ *
4
+ * Returns `{ apiGet, apiPut, apiDelete }` bound to one repo. Each call returns
5
+ * parsed JSON (or `null` on 404 for GET) and throws on other non-2xx responses.
6
+ */
7
+ export function createGitHubApi({ token, owner, repo }) {
8
+ const BASE = `https://api.github.com/repos/${owner}/${repo}`;
9
+
10
+ const headers = () => ({
11
+ Authorization: `Bearer ${token}`,
12
+ Accept: "application/vnd.github+json",
13
+ "X-GitHub-Api-Version": "2022-11-28",
14
+ "User-Agent": "natilon-cms",
15
+ "Content-Type": "application/json",
16
+ });
17
+
18
+ async function apiGet(path) {
19
+ const r = await fetch(`${BASE}${path}`, { headers: headers() });
20
+ if (r.status === 404) return null;
21
+ if (!r.ok) throw new Error(`GitHub API ${r.status} GET ${path}`);
22
+ return r.json();
23
+ }
24
+
25
+ async function apiPut(path, body) {
26
+ const r = await fetch(`${BASE}${path}`, {
27
+ method: "PUT",
28
+ headers: headers(),
29
+ body: JSON.stringify(body),
30
+ });
31
+ if (!r.ok) {
32
+ const err = await r.json().catch(() => ({}));
33
+ throw new Error(`GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
34
+ }
35
+ return r.json();
36
+ }
37
+
38
+ async function apiDelete(path, body) {
39
+ const r = await fetch(`${BASE}${path}`, {
40
+ method: "DELETE",
41
+ headers: headers(),
42
+ body: JSON.stringify(body),
43
+ });
44
+ if (!r.ok) throw new Error(`GitHub API ${r.status} DELETE ${path}`);
45
+ return r.json();
46
+ }
47
+
48
+ async function apiPost(path, body) {
49
+ const r = await fetch(`${BASE}${path}`, {
50
+ method: "POST",
51
+ headers: headers(),
52
+ body: JSON.stringify(body),
53
+ });
54
+ if (!r.ok) {
55
+ const err = await r.json().catch(() => ({}));
56
+ throw new Error(`GitHub API ${r.status} POST ${path}: ${err.message || ""}`);
57
+ }
58
+ return r.json();
59
+ }
60
+
61
+ async function apiPatch(path, body) {
62
+ const r = await fetch(`${BASE}${path}`, {
63
+ method: "PATCH",
64
+ headers: headers(),
65
+ body: JSON.stringify(body),
66
+ });
67
+ if (!r.ok) {
68
+ const err = await r.json().catch(() => ({}));
69
+ throw new Error(`GitHub API ${r.status} PATCH ${path}: ${err.message || ""}`);
70
+ }
71
+ return r.json();
72
+ }
73
+
74
+ async function graphql(query, variables = {}) {
75
+ const r = await fetch("https://api.github.com/graphql", {
76
+ method: "POST",
77
+ headers: {
78
+ Authorization: `Bearer ${token}`,
79
+ "User-Agent": "natilon-cms",
80
+ "Content-Type": "application/json",
81
+ },
82
+ body: JSON.stringify({ query, variables }),
83
+ });
84
+ if (!r.ok) throw new Error(`GitHub GraphQL ${r.status}`);
85
+ const json = await r.json();
86
+ if (json.errors?.length) throw new Error(json.errors[0].message);
87
+ return json.data;
88
+ }
89
+
90
+ return { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql };
91
+ }