@natilon/cms-server 0.3.0 → 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 +2 -2
- package/src/adapters/basic-auth.mjs +24 -9
- package/src/adapters/cloudflare-access.mjs +140 -0
- package/src/adapters/index.mjs +1 -0
- package/src/locks.mjs +64 -0
- package/src/routes.mjs +46 -1
- package/src/server.mjs +16 -7
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": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"bin"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@natilon/admin-ui": "^0.
|
|
38
|
+
"@natilon/admin-ui": "^0.5.0",
|
|
39
39
|
"express": "^4.21.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
@@ -21,11 +21,23 @@ function safeEq(a, b) {
|
|
|
21
21
|
export function createBasicAuth({
|
|
22
22
|
user,
|
|
23
23
|
pass,
|
|
24
|
+
users,
|
|
24
25
|
jwtSecret,
|
|
25
26
|
jwtTtl,
|
|
26
27
|
realm = "Admin",
|
|
27
28
|
}) {
|
|
28
|
-
|
|
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
|
+
}
|
|
29
41
|
|
|
30
42
|
function issueMediaToken(tenantId) {
|
|
31
43
|
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
@@ -55,11 +67,13 @@ export function createBasicAuth({
|
|
|
55
67
|
res.set("WWW-Authenticate", `Basic realm="${realm}"`);
|
|
56
68
|
return res.status(401).send("Authentication required");
|
|
57
69
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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" };
|
|
63
77
|
return next();
|
|
64
78
|
}
|
|
65
79
|
res.set("WWW-Authenticate", `Basic realm="${realm}"`);
|
|
@@ -68,14 +82,15 @@ export function createBasicAuth({
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
function verify(authHeader) {
|
|
71
|
-
if (!configured) return { login:
|
|
85
|
+
if (!configured) return { login: "admin", role: "admin" };
|
|
72
86
|
if (!authHeader?.startsWith("Basic ")) return null;
|
|
73
87
|
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
74
88
|
const colon = decoded.indexOf(":");
|
|
75
89
|
const u = decoded.slice(0, colon);
|
|
76
90
|
const p = decoded.slice(colon + 1);
|
|
77
|
-
|
|
78
|
-
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" };
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
return {
|
|
@@ -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
|
+
}
|
package/src/adapters/index.mjs
CHANGED
|
@@ -6,5 +6,6 @@ export { createLocalAssetsMedia } from "./local-assets-media.mjs";
|
|
|
6
6
|
export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
|
|
7
7
|
export { createBasicAuth } from "./basic-auth.mjs";
|
|
8
8
|
export { createGitHubOAuth } from "./github-oauth.mjs";
|
|
9
|
+
export { createCloudflareAccess } from "./cloudflare-access.mjs";
|
|
9
10
|
export { createNetlifyBuild } from "./build-netlify.mjs";
|
|
10
11
|
export { createMediaUrl } from "./media-url.mjs";
|
package/src/locks.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory content lock store.
|
|
3
|
+
*
|
|
4
|
+
* A lock is keyed by "collection/file" and holds the user who acquired it
|
|
5
|
+
* plus an expiry timestamp. Clients must send a heartbeat every ~30 s;
|
|
6
|
+
* locks that go 60 s without a heartbeat are considered stale and released
|
|
7
|
+
* automatically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOCK_TTL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/** @type {Map<string, { user: string, name: string, expiresAt: number }>} */
|
|
13
|
+
const locks = new Map();
|
|
14
|
+
|
|
15
|
+
function key(collection, file) {
|
|
16
|
+
return `${collection}/${file}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pruneExpired() {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
for (const [k, lock] of locks) {
|
|
22
|
+
if (lock.expiresAt <= now) locks.delete(k);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Try to acquire (or renew) a lock.
|
|
28
|
+
* Returns `{ ok: true, lock }` on success, `{ ok: false, lock }` if held by
|
|
29
|
+
* someone else.
|
|
30
|
+
*/
|
|
31
|
+
export function acquireLock(collection, file, userLogin, userName) {
|
|
32
|
+
pruneExpired();
|
|
33
|
+
const k = key(collection, file);
|
|
34
|
+
const existing = locks.get(k);
|
|
35
|
+
if (existing && existing.user !== userLogin) {
|
|
36
|
+
return { ok: false, lock: existing };
|
|
37
|
+
}
|
|
38
|
+
const lock = { user: userLogin, name: userName || userLogin, expiresAt: Date.now() + LOCK_TTL_MS };
|
|
39
|
+
locks.set(k, lock);
|
|
40
|
+
return { ok: true, lock };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Renew an existing lock. Returns false if not held by this user. */
|
|
44
|
+
export function renewLock(collection, file, userLogin) {
|
|
45
|
+
pruneExpired();
|
|
46
|
+
const k = key(collection, file);
|
|
47
|
+
const existing = locks.get(k);
|
|
48
|
+
if (!existing || existing.user !== userLogin) return false;
|
|
49
|
+
existing.expiresAt = Date.now() + LOCK_TTL_MS;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Release a lock. No-op if not held or held by someone else. */
|
|
54
|
+
export function releaseLock(collection, file, userLogin) {
|
|
55
|
+
const k = key(collection, file);
|
|
56
|
+
const existing = locks.get(k);
|
|
57
|
+
if (existing && existing.user === userLogin) locks.delete(k);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Check who holds a lock, or null if free. */
|
|
61
|
+
export function getLock(collection, file) {
|
|
62
|
+
pruneExpired();
|
|
63
|
+
return locks.get(key(collection, file)) ?? null;
|
|
64
|
+
}
|
package/src/routes.mjs
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
import { acquireLock, renewLock, releaseLock, getLock } from "./locks.mjs";
|
|
28
|
+
|
|
27
29
|
function ok(json) {
|
|
28
30
|
return { json };
|
|
29
31
|
}
|
|
@@ -87,7 +89,12 @@ export const apiRoutes = [
|
|
|
87
89
|
method: "PUT",
|
|
88
90
|
path: "/api/collections/:collection/:file",
|
|
89
91
|
auth: "any",
|
|
90
|
-
handler: async ({ adapters, params, body }) => {
|
|
92
|
+
handler: async ({ adapters, params, body, user }) => {
|
|
93
|
+
const login = user?.login || "admin";
|
|
94
|
+
const held = getLock(params.collection, params.file);
|
|
95
|
+
if (held && held.user !== login) {
|
|
96
|
+
return { status: 423, json: { error: `Locked by ${held.name}` } };
|
|
97
|
+
}
|
|
91
98
|
await adapters.content.writePage(params.collection, params.file, body);
|
|
92
99
|
return ok({ ok: true });
|
|
93
100
|
},
|
|
@@ -125,6 +132,44 @@ export const apiRoutes = [
|
|
|
125
132
|
},
|
|
126
133
|
},
|
|
127
134
|
|
|
135
|
+
// ── Locks ──────────────────────────────────────────────────────────────
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
path: "/api/locks/:collection/:file",
|
|
139
|
+
auth: "any",
|
|
140
|
+
handler: ({ params, user }) => {
|
|
141
|
+
const login = user?.login || "admin";
|
|
142
|
+
const name = user?.name || login;
|
|
143
|
+
const result = acquireLock(params.collection, params.file, login, name);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
return { status: 423, json: { error: `Locked by ${result.lock.name}`, lock: result.lock } };
|
|
146
|
+
}
|
|
147
|
+
return ok({ ok: true, lock: result.lock });
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
method: "PUT",
|
|
153
|
+
path: "/api/locks/:collection/:file",
|
|
154
|
+
auth: "any",
|
|
155
|
+
handler: ({ params, user }) => {
|
|
156
|
+
const login = user?.login || "admin";
|
|
157
|
+
const renewed = renewLock(params.collection, params.file, login);
|
|
158
|
+
if (!renewed) return { status: 409, json: { error: "Lock not held" } };
|
|
159
|
+
return ok({ ok: true });
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
method: "DELETE",
|
|
165
|
+
path: "/api/locks/:collection/:file",
|
|
166
|
+
auth: "any",
|
|
167
|
+
handler: ({ params, user }) => {
|
|
168
|
+
releaseLock(params.collection, params.file, user?.login || "admin");
|
|
169
|
+
return ok({ ok: true });
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
128
173
|
// ── History ────────────────────────────────────────────────────────────
|
|
129
174
|
{
|
|
130
175
|
method: "GET",
|
package/src/server.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createCdnProxyMedia,
|
|
13
13
|
createBasicAuth,
|
|
14
14
|
createGitHubOAuth,
|
|
15
|
+
createCloudflareAccess,
|
|
15
16
|
createNetlifyBuild,
|
|
16
17
|
createFsTemplates,
|
|
17
18
|
createGitHubTemplates,
|
|
@@ -61,13 +62,21 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
61
62
|
jwtTtl: config.auth.jwtTtl,
|
|
62
63
|
realm,
|
|
63
64
|
})
|
|
64
|
-
:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
});
|
|
71
80
|
|
|
72
81
|
const cdnMedia = createCdnProxyMedia({
|
|
73
82
|
baseUrl: config.media.cdnBase,
|