@natilon/cms-server 0.1.1 → 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 +8 -3
- package/src/adapters/_shared.mjs +125 -0
- package/src/adapters/basic-auth.mjs +9 -2
- package/src/adapters/cdn-proxy-media.mjs +3 -4
- 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 +2 -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/routes.mjs +394 -0
- package/src/server.mjs +75 -215
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
2
|
+
import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg } from "./_shared.mjs";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* GitHub Contents API adapter — serverless content backend.
|
|
3
6
|
*
|
|
@@ -28,48 +31,10 @@
|
|
|
28
31
|
* @returns {import('./types.mjs').ContentAdapter}
|
|
29
32
|
*/
|
|
30
33
|
export function createGitHubContent({ token, owner, repo, branch, pagesDir, commitMessage }) {
|
|
31
|
-
const
|
|
34
|
+
const { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql } = createGitHubApi({ token, owner, repo });
|
|
32
35
|
// SHA cache: avoid extra GET before every PUT. Invalidated on write.
|
|
33
36
|
const shaCache = new Map();
|
|
34
37
|
|
|
35
|
-
const headers = () => ({
|
|
36
|
-
Authorization: `Bearer ${token}`,
|
|
37
|
-
Accept: "application/vnd.github+json",
|
|
38
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
39
|
-
"User-Agent": "natilon-cms",
|
|
40
|
-
"Content-Type": "application/json",
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
async function apiGet(path) {
|
|
44
|
-
const r = await fetch(`${BASE}${path}`, { headers: headers() });
|
|
45
|
-
if (r.status === 404) return null;
|
|
46
|
-
if (!r.ok) throw new Error(`GitHub API ${r.status} GET ${path}`);
|
|
47
|
-
return r.json();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function apiPut(path, body) {
|
|
51
|
-
const r = await fetch(`${BASE}${path}`, {
|
|
52
|
-
method: "PUT",
|
|
53
|
-
headers: headers(),
|
|
54
|
-
body: JSON.stringify(body),
|
|
55
|
-
});
|
|
56
|
-
if (!r.ok) {
|
|
57
|
-
const err = await r.json().catch(() => ({}));
|
|
58
|
-
throw new Error(`GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
|
|
59
|
-
}
|
|
60
|
-
return r.json();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function apiDelete(path, body) {
|
|
64
|
-
const r = await fetch(`${BASE}${path}`, {
|
|
65
|
-
method: "DELETE",
|
|
66
|
-
headers: headers(),
|
|
67
|
-
body: JSON.stringify(body),
|
|
68
|
-
});
|
|
69
|
-
if (!r.ok) throw new Error(`GitHub API ${r.status} DELETE ${path}`);
|
|
70
|
-
return r.json();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
38
|
function contentPath(collection, file) {
|
|
74
39
|
return `${pagesDir}/${collection}/${file}`;
|
|
75
40
|
}
|
|
@@ -91,15 +56,6 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
91
56
|
return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
|
|
92
57
|
}
|
|
93
58
|
|
|
94
|
-
function commitMsg() {
|
|
95
|
-
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
96
|
-
return commitMessage ? commitMessage(ts) : `Content updated ${ts}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function sanitize(p) {
|
|
100
|
-
return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
59
|
return {
|
|
104
60
|
async listCollections() {
|
|
105
61
|
const items = await apiGet(`/contents/${pagesDir}?ref=${branch}`);
|
|
@@ -114,17 +70,22 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
114
70
|
);
|
|
115
71
|
},
|
|
116
72
|
|
|
117
|
-
async
|
|
73
|
+
async _listPagesRest(collection, sortConfig) {
|
|
118
74
|
const items = await apiGet(`/contents/${pagesDir}/${collection}?ref=${branch}`);
|
|
119
75
|
if (!Array.isArray(items)) return null;
|
|
120
76
|
const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
|
|
121
77
|
|
|
122
78
|
const pages = await Promise.all(
|
|
123
79
|
jsonFiles.map(async (item) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
80
|
+
shaCache.set(item.path, item.sha);
|
|
81
|
+
const r = await fetch(item.download_url, {
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${token}`,
|
|
84
|
+
"User-Agent": "natilon-cms",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
if (!r.ok) return null;
|
|
88
|
+
const data = await r.json();
|
|
128
89
|
return {
|
|
129
90
|
id: data.id,
|
|
130
91
|
slug: data.slug,
|
|
@@ -138,22 +99,83 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
138
99
|
);
|
|
139
100
|
|
|
140
101
|
const valid = pages.filter(Boolean);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
102
|
+
return sortPages(valid, sortConfig);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async listPages(collection, sortConfig = null) {
|
|
106
|
+
// Primary path: single GraphQL request fetches all blob contents at once.
|
|
107
|
+
// Falls back to the REST N+1 path if GraphQL throws.
|
|
108
|
+
const safeCollection = sanitize(collection);
|
|
109
|
+
const expression = `${branch}:${pagesDir}/${safeCollection}`;
|
|
110
|
+
const QUERY = `
|
|
111
|
+
query($owner: String!, $repo: String!, $expr: String!) {
|
|
112
|
+
repository(owner: $owner, name: $repo) {
|
|
113
|
+
object(expression: $expr) {
|
|
114
|
+
... on Tree {
|
|
115
|
+
entries {
|
|
116
|
+
name
|
|
117
|
+
oid
|
|
118
|
+
type
|
|
119
|
+
object {
|
|
120
|
+
... on Blob {
|
|
121
|
+
text
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
let useRest = false;
|
|
132
|
+
let gqlData = null;
|
|
133
|
+
try {
|
|
134
|
+
gqlData = await graphql(QUERY, { owner, repo, expr: expression });
|
|
135
|
+
} catch {
|
|
136
|
+
useRest = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!useRest) {
|
|
140
|
+
const treeObj = gqlData?.repository?.object;
|
|
141
|
+
// collection directory doesn't exist
|
|
142
|
+
if (treeObj === null || treeObj === undefined) {
|
|
143
|
+
// If GraphQL returned data but object is null, collection is missing
|
|
144
|
+
if (gqlData?.repository !== undefined) return null;
|
|
145
|
+
// Otherwise fall back
|
|
146
|
+
useRest = true;
|
|
147
|
+
} else {
|
|
148
|
+
const entries = treeObj.entries || [];
|
|
149
|
+
const pages = [];
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (entry.type !== "blob" || !entry.name.endsWith(".json")) continue;
|
|
152
|
+
const path = `${pagesDir}/${safeCollection}/${entry.name}`;
|
|
153
|
+
shaCache.set(path, entry.oid);
|
|
154
|
+
let data;
|
|
155
|
+
try {
|
|
156
|
+
data = JSON.parse(entry.object.text);
|
|
157
|
+
} catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
pages.push({
|
|
161
|
+
id: data.id,
|
|
162
|
+
slug: data.slug,
|
|
163
|
+
lang: data.lang,
|
|
164
|
+
collection: data.collection,
|
|
165
|
+
title: data.meta?.title || data.meta?.name || entry.name,
|
|
166
|
+
file: entry.name,
|
|
167
|
+
meta: data.meta || {},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return sortPages(pages, sortConfig);
|
|
171
|
+
}
|
|
152
172
|
}
|
|
153
|
-
|
|
173
|
+
|
|
174
|
+
return this._listPagesRest(collection, sortConfig);
|
|
154
175
|
},
|
|
155
176
|
|
|
156
177
|
async readPage(collection, file) {
|
|
178
|
+
if (!safeFileName(file)) return null;
|
|
157
179
|
const path = contentPath(collection, sanitize(file));
|
|
158
180
|
const data = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
159
181
|
if (!data || Array.isArray(data)) return null;
|
|
@@ -165,7 +187,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
165
187
|
const path = contentPath(collection, sanitize(file));
|
|
166
188
|
const sha = await getFileSha(path);
|
|
167
189
|
const result = await apiPut(`/contents/${path}`, {
|
|
168
|
-
message: commitMsg(),
|
|
190
|
+
message: commitMsg(commitMessage),
|
|
169
191
|
content: encodeContent(data),
|
|
170
192
|
branch,
|
|
171
193
|
...(sha ? { sha } : {}),
|
|
@@ -178,7 +200,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
178
200
|
const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
|
|
179
201
|
const path = contentPath(collection, fileName);
|
|
180
202
|
const result = await apiPut(`/contents/${path}`, {
|
|
181
|
-
message: commitMsg(),
|
|
203
|
+
message: commitMsg(commitMessage),
|
|
182
204
|
content: encodeContent(data),
|
|
183
205
|
branch,
|
|
184
206
|
});
|
|
@@ -187,37 +209,20 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
187
209
|
},
|
|
188
210
|
|
|
189
211
|
async deletePage(collection, file) {
|
|
212
|
+
if (!safeFileName(file)) return null;
|
|
190
213
|
const path = contentPath(collection, sanitize(file));
|
|
191
214
|
const sha = await getFileSha(path);
|
|
192
215
|
if (!sha) return false;
|
|
193
|
-
await apiDelete(`/contents/${path}`, { message: commitMsg(), sha, branch });
|
|
216
|
+
await apiDelete(`/contents/${path}`, { message: commitMsg(commitMessage), sha, branch });
|
|
194
217
|
shaCache.delete(path);
|
|
195
218
|
return true;
|
|
196
219
|
},
|
|
197
220
|
|
|
198
221
|
async duplicatePage(collection, file) {
|
|
222
|
+
if (!safeFileName(file)) return null;
|
|
199
223
|
const src = await this.readPage(collection, file);
|
|
200
224
|
if (!src) return null;
|
|
201
|
-
const
|
|
202
|
-
const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
|
|
203
|
-
const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
|
|
204
|
-
|
|
205
|
-
function reid(blocks) {
|
|
206
|
-
if (!Array.isArray(blocks)) return blocks;
|
|
207
|
-
return blocks.map((b) => {
|
|
208
|
-
const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
|
|
209
|
-
if (Array.isArray(b.children)) next.children = b.children.map((col) => reid(col));
|
|
210
|
-
return next;
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const data = {
|
|
215
|
-
...src,
|
|
216
|
-
id: `${lang}/${newSlug}`,
|
|
217
|
-
slug: newSlug,
|
|
218
|
-
meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
|
|
219
|
-
blocks: reid(src.blocks || []),
|
|
220
|
-
};
|
|
225
|
+
const { data, fileName } = buildDuplicateData(src);
|
|
221
226
|
return this.createPage(collection, data);
|
|
222
227
|
},
|
|
223
228
|
|
|
@@ -232,6 +237,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
232
237
|
},
|
|
233
238
|
|
|
234
239
|
async listHistory(collection, file) {
|
|
240
|
+
if (!safeFileName(file)) return null;
|
|
235
241
|
const path = contentPath(collection, sanitize(file));
|
|
236
242
|
const commits = await apiGet(`/commits?path=${encodeURIComponent(path)}&per_page=20&sha=${branch}`);
|
|
237
243
|
if (!Array.isArray(commits)) return [];
|
|
@@ -245,6 +251,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
245
251
|
},
|
|
246
252
|
|
|
247
253
|
async restoreHistory(collection, file, commitSha) {
|
|
254
|
+
if (!safeFileName(file)) return null;
|
|
248
255
|
const path = contentPath(collection, sanitize(file));
|
|
249
256
|
const fileData = await apiGet(`/contents/${path}?ref=${commitSha}`);
|
|
250
257
|
if (!fileData || Array.isArray(fileData)) return false;
|
|
@@ -253,20 +260,54 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
|
|
|
253
260
|
return true;
|
|
254
261
|
},
|
|
255
262
|
|
|
256
|
-
async
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
263
|
+
async writeBatch(items, message) {
|
|
264
|
+
if (!items.length) return { ok: true, commitCount: 0 };
|
|
265
|
+
|
|
266
|
+
// 1. Get current ref → base commit SHA
|
|
267
|
+
const refData = await apiGet(`/git/ref/heads/${branch}`);
|
|
268
|
+
const baseCommitSha = refData.object.sha;
|
|
269
|
+
|
|
270
|
+
// 2. Get base commit → base tree SHA
|
|
271
|
+
const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
|
|
272
|
+
const baseTreeSha = baseCommit.tree.sha;
|
|
273
|
+
|
|
274
|
+
// 3. Create one blob per file
|
|
275
|
+
const treeItems = await Promise.all(
|
|
276
|
+
items.map(async ({ collection, file, data }) => {
|
|
277
|
+
const filePath = contentPath(sanitize(collection), sanitize(file));
|
|
278
|
+
const content = Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64");
|
|
279
|
+
const blob = await apiPost(`/git/blobs`, { content, encoding: "base64" });
|
|
280
|
+
return { path: filePath, mode: "100644", type: "blob", sha: blob.sha };
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// 4. Create tree
|
|
285
|
+
const newTree = await apiPost(`/git/trees`, {
|
|
286
|
+
base_tree: baseTreeSha,
|
|
287
|
+
tree: treeItems,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// 5. Create commit
|
|
291
|
+
const msg = message || commitMsg(commitMessage, "Batch content update");
|
|
292
|
+
const newCommit = await apiPost(`/git/commits`, {
|
|
293
|
+
message: msg,
|
|
294
|
+
tree: newTree.sha,
|
|
295
|
+
parents: [baseCommitSha],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// 6. Update ref
|
|
299
|
+
await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
|
|
300
|
+
|
|
301
|
+
// 7. Invalidate shaCache for all written paths
|
|
302
|
+
for (const { collection, file } of items) {
|
|
303
|
+
shaCache.delete(contentPath(sanitize(collection), sanitize(file)));
|
|
268
304
|
}
|
|
269
|
-
|
|
305
|
+
|
|
306
|
+
return { ok: true, sha: newCommit.sha, commitCount: 1 };
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async listScheduled() {
|
|
310
|
+
return listScheduledDue(this);
|
|
270
311
|
},
|
|
271
312
|
};
|
|
272
313
|
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
|
|
3
|
-
const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
|
|
4
|
-
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
5
|
-
const GITHUB_USER_URL = "https://api.github.com/user";
|
|
6
|
-
|
|
7
3
|
/**
|
|
8
4
|
* GitHub OAuth adapter — drop-in replacement for createBasicAuth.
|
|
9
5
|
*
|
|
@@ -73,7 +69,26 @@ export function createGitHubOAuth({
|
|
|
73
69
|
const parts = (tokenStr || "").split(".");
|
|
74
70
|
if (parts.length !== 3) return null;
|
|
75
71
|
const [header, payload, sig] = parts;
|
|
76
|
-
|
|
72
|
+
|
|
73
|
+
// Decode and validate header
|
|
74
|
+
try {
|
|
75
|
+
const headerData = JSON.parse(Buffer.from(header, "base64url").toString());
|
|
76
|
+
if (headerData.alg !== "HS256" || headerData.typ !== "JWT") return null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Timing-safe signature comparison
|
|
82
|
+
const expectedSig = sign(header, payload);
|
|
83
|
+
const sigBuf = Buffer.from(sig, "base64url");
|
|
84
|
+
const expectedBuf = Buffer.from(expectedSig, "base64url");
|
|
85
|
+
if (sigBuf.length !== expectedBuf.length) return null;
|
|
86
|
+
try {
|
|
87
|
+
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
try {
|
|
78
93
|
const data = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
79
94
|
if (data.exp < Math.floor(Date.now() / 1000)) return null;
|
|
@@ -98,57 +113,6 @@ export function createGitHubOAuth({
|
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
101
|
-
// OAuth route handlers — mounted by server.mjs
|
|
102
|
-
const oauthRoutes = {
|
|
103
|
-
login(req, res) {
|
|
104
|
-
const state = crypto.randomBytes(16).toString("hex");
|
|
105
|
-
const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
|
|
106
|
-
const params = new URLSearchParams({
|
|
107
|
-
client_id: clientId,
|
|
108
|
-
redirect_uri: callbackUrl,
|
|
109
|
-
scope: "read:user",
|
|
110
|
-
state,
|
|
111
|
-
});
|
|
112
|
-
// state is stateless here — for production add a cookie/session store
|
|
113
|
-
res.redirect(`${GITHUB_AUTH_URL}?${params.toString()}`);
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
async callback(req, res) {
|
|
117
|
-
const { code } = req.query;
|
|
118
|
-
if (!code) return res.status(400).send("Missing code");
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
|
|
122
|
-
|
|
123
|
-
// Exchange code for GitHub access token
|
|
124
|
-
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
|
|
125
|
-
method: "POST",
|
|
126
|
-
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
127
|
-
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: callbackUrl }),
|
|
128
|
-
});
|
|
129
|
-
const tokenData = await tokenRes.json();
|
|
130
|
-
if (!tokenData.access_token) {
|
|
131
|
-
return res.status(401).send("GitHub OAuth failed: " + (tokenData.error_description || tokenData.error || "unknown"));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Get GitHub user identity
|
|
135
|
-
const userRes = await fetch(GITHUB_USER_URL, {
|
|
136
|
-
headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "natilon-cms" },
|
|
137
|
-
});
|
|
138
|
-
const user = await userRes.json();
|
|
139
|
-
|
|
140
|
-
if (allowedLogins.length > 0 && !allowedLogins.includes(user.login)) {
|
|
141
|
-
return res.status(403).send(`GitHub user "${user.login}" is not authorised for this CMS.`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const sessionToken = issueToken({ sub: user.login, type: "session", name: user.name || user.login });
|
|
145
|
-
res.redirect(`/admin/?token=${encodeURIComponent(sessionToken)}`);
|
|
146
|
-
} catch (err) {
|
|
147
|
-
res.status(500).send("OAuth error: " + err.message);
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
|
|
152
116
|
function verify(authHeader) {
|
|
153
117
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
154
118
|
const claims = verifyToken(authHeader.slice(7));
|
|
@@ -160,6 +124,24 @@ export function createGitHubOAuth({
|
|
|
160
124
|
return issueToken({ sub: login, type: "session", name });
|
|
161
125
|
}
|
|
162
126
|
|
|
127
|
+
function issueOAuthState() {
|
|
128
|
+
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
129
|
+
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
130
|
+
const now = Math.floor(Date.now() / 1000);
|
|
131
|
+
const payload = Buffer.from(JSON.stringify({
|
|
132
|
+
type: "oauth-state",
|
|
133
|
+
nonce: crypto.randomBytes(16).toString("hex"),
|
|
134
|
+
iat: now,
|
|
135
|
+
exp: now + 600,
|
|
136
|
+
})).toString("base64url");
|
|
137
|
+
return `${header}.${payload}.${sign(header, payload)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function verifyOAuthState(state) {
|
|
141
|
+
const claims = verifyToken(state);
|
|
142
|
+
return Boolean(claims && claims.type === "oauth-state");
|
|
143
|
+
}
|
|
144
|
+
|
|
163
145
|
return {
|
|
164
146
|
configured,
|
|
165
147
|
middleware: middlewareWith(null),
|
|
@@ -169,6 +151,7 @@ export function createGitHubOAuth({
|
|
|
169
151
|
},
|
|
170
152
|
verify,
|
|
171
153
|
issueSessionToken,
|
|
172
|
-
|
|
154
|
+
issueOAuthState,
|
|
155
|
+
verifyOAuthState,
|
|
173
156
|
};
|
|
174
157
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
2
|
+
import { sanitize, safeFileName, commitMsg } from "./_shared.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub Contents API-backed block-template adapter.
|
|
6
|
+
*
|
|
7
|
+
* Templates are stored under `<templatesDir>/<slug>.json` on a configurable
|
|
8
|
+
* branch. Safe to run on any serverless runtime — no local filesystem.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} opts
|
|
11
|
+
* @param {string} opts.token GitHub PAT with repo scope
|
|
12
|
+
* @param {string} opts.owner
|
|
13
|
+
* @param {string} opts.repo
|
|
14
|
+
* @param {string} opts.branch
|
|
15
|
+
* @param {string} [opts.templatesDir=".cms-templates"]
|
|
16
|
+
* @param {(ts: string) => string} [opts.commitMessage]
|
|
17
|
+
* @returns {import('./types.mjs').TemplatesAdapter}
|
|
18
|
+
*/
|
|
19
|
+
export function createGitHubTemplates({
|
|
20
|
+
token,
|
|
21
|
+
owner,
|
|
22
|
+
repo,
|
|
23
|
+
branch,
|
|
24
|
+
templatesDir = ".cms-templates",
|
|
25
|
+
commitMessage,
|
|
26
|
+
}) {
|
|
27
|
+
const { apiGet, apiPut, apiDelete } = createGitHubApi({ token, owner, repo });
|
|
28
|
+
const shaCache = new Map();
|
|
29
|
+
|
|
30
|
+
function filePath(slug) {
|
|
31
|
+
return `${templatesDir}/${sanitize(slug)}.json`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function msg() {
|
|
35
|
+
return commitMsg(commitMessage, "Template updated");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async list() {
|
|
40
|
+
const items = await apiGet(`/contents/${templatesDir}?ref=${branch}`);
|
|
41
|
+
if (!items || !Array.isArray(items)) return [];
|
|
42
|
+
const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
|
|
43
|
+
return Promise.all(
|
|
44
|
+
jsonFiles.map(async (item) => {
|
|
45
|
+
shaCache.set(item.path, item.sha);
|
|
46
|
+
const r = await fetch(item.download_url, {
|
|
47
|
+
headers: { Authorization: `Bearer ${token}`, "User-Agent": "natilon-cms" },
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok) return null;
|
|
50
|
+
const data = await r.json();
|
|
51
|
+
return {
|
|
52
|
+
name: data.name,
|
|
53
|
+
slug: item.name.replace(".json", ""),
|
|
54
|
+
blockCount: (data.blocks || []).length,
|
|
55
|
+
};
|
|
56
|
+
}),
|
|
57
|
+
).then((rs) => rs.filter(Boolean));
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async get(slug) {
|
|
61
|
+
if (!safeFileName(slug)) return null;
|
|
62
|
+
const path = filePath(slug);
|
|
63
|
+
const data = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
64
|
+
if (!data || Array.isArray(data)) return null;
|
|
65
|
+
shaCache.set(path, data.sha);
|
|
66
|
+
return JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async put(slug, data) {
|
|
70
|
+
if (!safeFileName(slug)) return null;
|
|
71
|
+
const path = filePath(slug);
|
|
72
|
+
let sha = shaCache.get(path);
|
|
73
|
+
if (!sha) {
|
|
74
|
+
const existing = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
75
|
+
if (existing && !Array.isArray(existing)) sha = existing.sha;
|
|
76
|
+
}
|
|
77
|
+
const result = await apiPut(`/contents/${path}`, {
|
|
78
|
+
message: msg(),
|
|
79
|
+
content: Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64"),
|
|
80
|
+
branch,
|
|
81
|
+
...(sha ? { sha } : {}),
|
|
82
|
+
});
|
|
83
|
+
shaCache.set(path, result.content?.sha);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async delete(slug) {
|
|
87
|
+
if (!safeFileName(slug)) return null;
|
|
88
|
+
const path = filePath(slug);
|
|
89
|
+
let sha = shaCache.get(path);
|
|
90
|
+
if (!sha) {
|
|
91
|
+
const existing = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
92
|
+
if (!existing || Array.isArray(existing)) return false;
|
|
93
|
+
sha = existing.sha;
|
|
94
|
+
}
|
|
95
|
+
await apiDelete(`/contents/${path}`, { message: msg(), sha, branch });
|
|
96
|
+
shaCache.delete(path);
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/adapters/index.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createFsJsonContent } from "./fs-json-content.mjs";
|
|
2
|
+
export { createFsTemplates } from "./fs-templates.mjs";
|
|
2
3
|
export { createGitHubContent } from "./github-content.mjs";
|
|
4
|
+
export { createGitHubTemplates } from "./github-templates.mjs";
|
|
3
5
|
export { createLocalAssetsMedia } from "./local-assets-media.mjs";
|
|
4
6
|
export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
|
|
5
7
|
export { createBasicAuth } from "./basic-auth.mjs";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { sanitize } from "./_shared.mjs";
|
|
3
4
|
|
|
4
5
|
const IMAGE_RE = /\.(png|jpg|jpeg|svg|webp|gif)$/i;
|
|
5
6
|
|
|
@@ -16,10 +17,6 @@ export function createLocalAssetsMedia({ rootDir, assetsDir }) {
|
|
|
16
17
|
const ROOT = path.join(rootDir, assetsDir);
|
|
17
18
|
const URL_PREFIX = `/${assetsDir.replace(/^\/+/, "")}`;
|
|
18
19
|
|
|
19
|
-
function sanitize(p) {
|
|
20
|
-
return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
20
|
function listImages(dir, prefix) {
|
|
24
21
|
const out = [];
|
|
25
22
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
package/src/adapters/types.mjs
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
* @property {(collection: string, file: string) => Promise<{file: string}|null>} duplicatePage
|
|
53
53
|
* @property {() => Promise<PendingChanges>} pendingChanges
|
|
54
54
|
* @property {() => Promise<PublishResult>} publish
|
|
55
|
+
* @property {(items: Array<{collection: string, file: string, data: object}>, message?: string) => Promise<{ok: boolean, sha?: string, commitCount: number}>} writeBatch
|
|
55
56
|
*/
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -75,4 +76,19 @@
|
|
|
75
76
|
* @property {(opts?: {branch?: string, sha?: string}) => Promise<object>} getDeployStatus
|
|
76
77
|
*/
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {Object} TemplateSummary
|
|
81
|
+
* @property {string} name
|
|
82
|
+
* @property {string} slug
|
|
83
|
+
* @property {number} blockCount
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} TemplatesAdapter
|
|
88
|
+
* @property {() => Promise<TemplateSummary[]>} list
|
|
89
|
+
* @property {(slug: string) => Promise<object|null>} get
|
|
90
|
+
* @property {(slug: string, data: object) => Promise<void>} put
|
|
91
|
+
* @property {(slug: string) => Promise<boolean>} delete
|
|
92
|
+
*/
|
|
93
|
+
|
|
78
94
|
export {};
|
|
@@ -7,18 +7,21 @@
|
|
|
7
7
|
* Used automatically by createCmsServer when the consumer does not supply
|
|
8
8
|
* their own publicConfig function.
|
|
9
9
|
*
|
|
10
|
-
* @param {Object} config
|
|
11
|
-
* @
|
|
10
|
+
* @param {Object} config Full cms.config object
|
|
11
|
+
* @param {Object} [overrides={}] Extra fields merged in at the end (shallow)
|
|
12
|
+
* @returns {Object} Safe subset for the browser
|
|
12
13
|
*/
|
|
13
|
-
export function defaultPublicConfig(config) {
|
|
14
|
-
|
|
15
|
-
mountPath:
|
|
16
|
-
locales:
|
|
17
|
-
defaultLocale:
|
|
18
|
-
previewUrlPattern:
|
|
19
|
-
media:
|
|
20
|
-
collections:
|
|
21
|
-
blocks:
|
|
22
|
-
content:
|
|
14
|
+
export function defaultPublicConfig(config, overrides = {}) {
|
|
15
|
+
const cfg = {
|
|
16
|
+
mountPath: config.mountPath,
|
|
17
|
+
locales: config.locales,
|
|
18
|
+
defaultLocale: config.defaultLocale,
|
|
19
|
+
previewUrlPattern: config.previewUrlPattern,
|
|
20
|
+
media: config.media,
|
|
21
|
+
collections: config.collections,
|
|
22
|
+
blocks: config.blocks,
|
|
23
|
+
content: { provider: config.content?.provider || "fs" },
|
|
24
|
+
auth: { provider: config.auth?.provider || "basic" },
|
|
23
25
|
};
|
|
26
|
+
return Object.assign(cfg, overrides);
|
|
24
27
|
}
|