@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/package.json +8 -4
- package/src/adapters/_shared.mjs +125 -0
- package/src/adapters/basic-auth.mjs +31 -9
- package/src/adapters/cdn-proxy-media.mjs +3 -4
- package/src/adapters/cloudflare-access.mjs +140 -0
- package/src/adapters/fs-json-content.mjs +17 -60
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +91 -0
- package/src/adapters/github-content.mjs +142 -101
- package/src/adapters/github-oauth.mjs +40 -57
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +3 -0
- package/src/adapters/local-assets-media.mjs +1 -4
- package/src/adapters/types.mjs +16 -0
- package/src/default-public-config.mjs +15 -12
- package/src/express-shim.mjs +51 -0
- package/src/hono-shim.mjs +108 -0
- package/src/index.mjs +2 -0
- package/src/locks.mjs +64 -0
- package/src/routes.mjs +439 -0
- package/src/server.mjs +91 -222
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natilon/cms-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.
|
|
34
|
-
"depd": "^2.0.0",
|
|
38
|
+
"@natilon/admin-ui": "^0.5.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,14 +11,33 @@ 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,
|
|
24
|
+
users,
|
|
17
25
|
jwtSecret,
|
|
18
26
|
jwtTtl,
|
|
19
27
|
realm = "Admin",
|
|
20
28
|
}) {
|
|
21
|
-
|
|
29
|
+
// Normalise: prefer `users` array, fall back to single user/pass pair.
|
|
30
|
+
const userList = users?.length
|
|
31
|
+
? users
|
|
32
|
+
: pass ? [{ user: user || "admin", pass, role: "admin" }] : [];
|
|
33
|
+
|
|
34
|
+
const configured = userList.length > 0;
|
|
35
|
+
|
|
36
|
+
function findUser(u, p) {
|
|
37
|
+
return userList.find(
|
|
38
|
+
(entry) => safeEq(u, entry.user) && safeEq(p, entry.pass),
|
|
39
|
+
) ?? null;
|
|
40
|
+
}
|
|
22
41
|
|
|
23
42
|
function issueMediaToken(tenantId) {
|
|
24
43
|
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
@@ -48,11 +67,13 @@ export function createBasicAuth({
|
|
|
48
67
|
res.set("WWW-Authenticate", `Basic realm="${realm}"`);
|
|
49
68
|
return res.status(401).send("Authentication required");
|
|
50
69
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
const decoded = Buffer.from(auth.slice(6), "base64").toString();
|
|
71
|
+
const colon = decoded.indexOf(":");
|
|
72
|
+
const u = decoded.slice(0, colon);
|
|
73
|
+
const p = decoded.slice(colon + 1);
|
|
74
|
+
const entry = findUser(u, p);
|
|
75
|
+
if (entry) {
|
|
76
|
+
req.cmsUser = { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
|
|
56
77
|
return next();
|
|
57
78
|
}
|
|
58
79
|
res.set("WWW-Authenticate", `Basic realm="${realm}"`);
|
|
@@ -61,14 +82,15 @@ export function createBasicAuth({
|
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
function verify(authHeader) {
|
|
64
|
-
if (!configured) return { login:
|
|
85
|
+
if (!configured) return { login: "admin", role: "admin" };
|
|
65
86
|
if (!authHeader?.startsWith("Basic ")) return null;
|
|
66
87
|
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
67
88
|
const colon = decoded.indexOf(":");
|
|
68
89
|
const u = decoded.slice(0, colon);
|
|
69
90
|
const p = decoded.slice(colon + 1);
|
|
70
|
-
|
|
71
|
-
return null;
|
|
91
|
+
const entry = findUser(u, p);
|
|
92
|
+
if (!entry) return null;
|
|
93
|
+
return { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
return {
|
|
@@ -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,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Access auth adapter.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the `Cf-Access-Jwt-Assertion` header (or `CF_Authorization` cookie)
|
|
5
|
+
* signed by your Cloudflare Access team using RS256. No third-party libraries —
|
|
6
|
+
* uses Node's built-in `crypto` module.
|
|
7
|
+
*
|
|
8
|
+
* cms.config.mjs:
|
|
9
|
+
* auth: {
|
|
10
|
+
* provider: "cloudflare-access",
|
|
11
|
+
* teamDomain: "https://yourteam.cloudflareaccess.com",
|
|
12
|
+
* audience: "your-application-audience-tag", // from Access app settings
|
|
13
|
+
* roles: { // optional: map email → role
|
|
14
|
+
* "fatih@example.com": "admin",
|
|
15
|
+
* },
|
|
16
|
+
* defaultRole: "editor", // role when email not in roles map
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import crypto from "crypto";
|
|
21
|
+
|
|
22
|
+
const JWKS_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} opts
|
|
26
|
+
* @param {string} opts.teamDomain e.g. "https://yourteam.cloudflareaccess.com"
|
|
27
|
+
* @param {string} opts.audience Application Audience tag from Cloudflare Access
|
|
28
|
+
* @param {Object} [opts.roles] { "email": "admin" | "editor" }
|
|
29
|
+
* @param {string} [opts.defaultRole]
|
|
30
|
+
*/
|
|
31
|
+
export function createCloudflareAccess({ teamDomain, audience, roles = {}, defaultRole = "editor" }) {
|
|
32
|
+
const domain = teamDomain.replace(/\/$/, "");
|
|
33
|
+
const certsUrl = `${domain}/cdn-cgi/access/certs`;
|
|
34
|
+
|
|
35
|
+
// JWKS cache
|
|
36
|
+
let jwksCache = null;
|
|
37
|
+
let jwksCachedAt = 0;
|
|
38
|
+
|
|
39
|
+
async function getJwks() {
|
|
40
|
+
if (jwksCache && Date.now() - jwksCachedAt < JWKS_TTL_MS) return jwksCache;
|
|
41
|
+
const res = await fetch(certsUrl);
|
|
42
|
+
if (!res.ok) throw new Error(`Failed to fetch Cloudflare Access JWKS: ${res.status}`);
|
|
43
|
+
jwksCache = await res.json();
|
|
44
|
+
jwksCachedAt = Date.now();
|
|
45
|
+
return jwksCache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function base64urlDecode(str) {
|
|
49
|
+
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function importPublicKey(jwk) {
|
|
53
|
+
// Cloudflare Access JWKS uses x5c (certificate chain) or n/e (RSA components).
|
|
54
|
+
if (jwk.x5c?.length) {
|
|
55
|
+
const pem = `-----BEGIN CERTIFICATE-----\n${jwk.x5c[0].match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----`;
|
|
56
|
+
return crypto.createPublicKey(pem);
|
|
57
|
+
}
|
|
58
|
+
if (jwk.n && jwk.e) {
|
|
59
|
+
return crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
60
|
+
}
|
|
61
|
+
throw new Error("Unsupported JWK format — no x5c or n/e fields");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function verifyToken(token) {
|
|
65
|
+
const parts = token.split(".");
|
|
66
|
+
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
|
67
|
+
|
|
68
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
69
|
+
const header = JSON.parse(base64urlDecode(headerB64).toString());
|
|
70
|
+
const payload = JSON.parse(base64urlDecode(payloadB64).toString());
|
|
71
|
+
|
|
72
|
+
// Claim validation
|
|
73
|
+
const now = Math.floor(Date.now() / 1000);
|
|
74
|
+
if (payload.exp && payload.exp < now) throw new Error("JWT expired");
|
|
75
|
+
if (payload.nbf && payload.nbf > now) throw new Error("JWT not yet valid");
|
|
76
|
+
if (payload.iss !== domain) throw new Error(`JWT issuer mismatch: ${payload.iss}`);
|
|
77
|
+
if (audience && payload.aud !== audience && !payload.aud?.includes?.(audience)) {
|
|
78
|
+
throw new Error("JWT audience mismatch");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Signature verification
|
|
82
|
+
const jwks = await getJwks();
|
|
83
|
+
const jwk = jwks.keys?.find((k) => k.kid === header.kid) ?? jwks.keys?.[0];
|
|
84
|
+
if (!jwk) throw new Error("No matching JWK found");
|
|
85
|
+
|
|
86
|
+
const pubKey = importPublicKey(jwk);
|
|
87
|
+
const verify = crypto.createVerify("RSA-SHA256");
|
|
88
|
+
verify.update(`${headerB64}.${payloadB64}`);
|
|
89
|
+
const valid = verify.verify(pubKey, base64urlDecode(sigB64));
|
|
90
|
+
if (!valid) throw new Error("JWT signature invalid");
|
|
91
|
+
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractToken(req) {
|
|
96
|
+
const header = req.headers["cf-access-jwt-assertion"];
|
|
97
|
+
if (header) return header;
|
|
98
|
+
// Fall back to cookie
|
|
99
|
+
const cookie = req.headers.cookie || "";
|
|
100
|
+
const match = cookie.match(/(?:^|;\s*)CF_Authorization=([^;]+)/);
|
|
101
|
+
return match ? match[1] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveUser(payload) {
|
|
105
|
+
const email = payload.email || payload.sub;
|
|
106
|
+
const role = roles[email] ?? defaultRole;
|
|
107
|
+
return { login: email, name: email, role };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function middlewareWith(allowlist) {
|
|
111
|
+
return async (req, res, next) => {
|
|
112
|
+
if (allowlist && allowlist(req)) return next();
|
|
113
|
+
const token = extractToken(req);
|
|
114
|
+
if (!token) {
|
|
115
|
+
return res.status(401).json({ error: "Cloudflare Access token required" });
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const payload = await verifyToken(token);
|
|
119
|
+
req.cmsUser = resolveUser(payload);
|
|
120
|
+
return next();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return res.status(401).json({ error: `Access denied: ${err.message}` });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function verify(authHeader) {
|
|
128
|
+
// authHeader unused — Cloudflare Access uses its own header/cookie
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
configured: Boolean(teamDomain && audience),
|
|
134
|
+
middleware: middlewareWith(null),
|
|
135
|
+
middlewareWith,
|
|
136
|
+
verify,
|
|
137
|
+
// issueMediaToken not supported for Cloudflare Access — use Cloudflare's own CDN auth
|
|
138
|
+
issueMediaToken: () => { throw new Error("issueMediaToken not supported for cloudflare-access"); },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
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
|
|
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
|
+
}
|