@charlescms/astro 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.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/SECURITY.md +77 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/connector/worker.js +505 -0
- package/connector/wrangler.toml +15 -0
- package/package.json +92 -0
- package/scripts/check-licenses.js +45 -0
- package/scripts/check-package.js +62 -0
- package/scripts/setup.js +719 -0
- package/scripts/update-vendored-site.js +71 -0
- package/src/admin.astro +314 -0
- package/src/analyzer.js +639 -0
- package/src/asset-images.js +130 -0
- package/src/astro-frontmatter.js +17 -0
- package/src/boot.js +35 -0
- package/src/client.js +347 -0
- package/src/connector-client.js +185 -0
- package/src/content-bridge.js +162 -0
- package/src/content-panel.js +440 -0
- package/src/data-analyzer.js +304 -0
- package/src/edit-affordance.js +463 -0
- package/src/editor-styles.js +243 -0
- package/src/element-editor.js +355 -0
- package/src/fields.js +6 -0
- package/src/frontmatter.js +153 -0
- package/src/ids.js +20 -0
- package/src/index.js +681 -0
- package/src/js-ast.js +140 -0
- package/src/markdown-analyzer.js +95 -0
- package/src/media-preview.js +58 -0
- package/src/panel-manager.js +133 -0
- package/src/publishing.js +457 -0
- package/src/rich-text-editor.js +209 -0
- package/src/routes.js +21 -0
- package/src/runtime-controller.js +206 -0
- package/src/sanitize.js +150 -0
- package/src/section-editor.js +437 -0
- package/src/source-edit.js +310 -0
- package/src/source-map-runtime.js +184 -0
- package/src/staged-panel.js +145 -0
- package/src/toolbar.js +128 -0
- package/src/versions-panel.js +112 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
const githubApiBase = "https://api.github.com";
|
|
2
|
+
const tokenCache = new Map();
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
async fetch(request, env) {
|
|
6
|
+
const origin = request.headers.get("origin") || "";
|
|
7
|
+
const cors = corsHeaders(origin, env);
|
|
8
|
+
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
if (!isAllowedOrigin(origin, env)) {
|
|
12
|
+
return json({ error: "Origin is not allowed by this CharlesCMS connector." }, 403, cors);
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(request.url);
|
|
15
|
+
// The unauthenticated health ping (used by `doctor` and uptime checks)
|
|
16
|
+
// returns before auth — only /api/* actions require the editor password.
|
|
17
|
+
if (request.method !== "POST" || !url.pathname.startsWith("/api/")) {
|
|
18
|
+
return json({ ok: true, service: "charlescms-connector" }, 200, cors);
|
|
19
|
+
}
|
|
20
|
+
const auth = await authorizeRequest(request, env);
|
|
21
|
+
if (!auth.ok) {
|
|
22
|
+
return json({ error: auth.error || "Not authorized to edit this site." }, auth.status || 401, cors);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const action = url.pathname.slice("/api/".length);
|
|
26
|
+
const body = await request.json().catch(() => ({}));
|
|
27
|
+
const repo = normalizeRepo(body.repo);
|
|
28
|
+
if (!isAllowedRepo(repo, env)) {
|
|
29
|
+
return json({ error: "Repository is not allowed by this CharlesCMS connector." }, 403, cors);
|
|
30
|
+
}
|
|
31
|
+
const branch = String(body.branch || env.DEFAULT_BRANCH || env.GITHUB_BRANCH || "main");
|
|
32
|
+
let path = "";
|
|
33
|
+
let sha;
|
|
34
|
+
if (action === "file") path = safeCmsPath(body.path);
|
|
35
|
+
if (action === "write") {
|
|
36
|
+
path = safeSourcePath(body.path);
|
|
37
|
+
sha = requiredSha(body.sha);
|
|
38
|
+
}
|
|
39
|
+
if (action === "upload") {
|
|
40
|
+
path = safePublicPath(body.path);
|
|
41
|
+
sha = body.sha ? requiredSha(body.sha) : undefined;
|
|
42
|
+
if (!sha && !isNewUploadPath(path)) {
|
|
43
|
+
throw new ConnectorHttpError("New media files may only be created in public/uploads.", 400);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const providerApi = await createGitHubApi(env, repo);
|
|
47
|
+
|
|
48
|
+
if (action === "verify") {
|
|
49
|
+
const repository = await providerApi.repo(repo);
|
|
50
|
+
return json({
|
|
51
|
+
defaultBranch: repository.default_branch || "main",
|
|
52
|
+
private: Boolean(repository.private)
|
|
53
|
+
}, 200, cors);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (action === "file") {
|
|
57
|
+
const file = await providerApi.getFile(repo, path, String(body.ref || branch));
|
|
58
|
+
return json(file, 200, cors);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === "write") {
|
|
62
|
+
const commit = await providerApi.putFile(repo, path, String(body.source ?? ""), {
|
|
63
|
+
branch,
|
|
64
|
+
sha,
|
|
65
|
+
message: body.message || `Update content: ${path}`
|
|
66
|
+
});
|
|
67
|
+
return json(commit, 200, cors);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (action === "upload") {
|
|
71
|
+
const commit = await providerApi.putBase64(repo, path, String(body.base64 || ""), {
|
|
72
|
+
branch,
|
|
73
|
+
sha,
|
|
74
|
+
message: body.message || `Upload media: ${path}`
|
|
75
|
+
});
|
|
76
|
+
return json(commit, 200, cors);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (action === "commits") {
|
|
80
|
+
const commits = await providerApi.listCommits(repo, branch, body.limit);
|
|
81
|
+
return json({ commits }, 200, cors);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (action === "revert") {
|
|
85
|
+
const result = await providerApi.revertCommit(repo, branch, String(body.sha || ""));
|
|
86
|
+
return json(result, 200, cors);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return json({ error: "Unknown connector action." }, 404, cors);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return json({ error: error.message || "Connector failed." }, error.status || 500, cors);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── Editor auth ──────────────────────────────────────────────────────────────
|
|
97
|
+
// Editing/publishing requires the shared editor secret AUTH_SECRET (sent as the
|
|
98
|
+
// "x-charlescms-auth" header), checked in constant time, on top of the origin +
|
|
99
|
+
// repo allow-lists. The connector FAILS CLOSED: with no AUTH_SECRET set it
|
|
100
|
+
// refuses every action, so a misconfigured deploy can never publish openly.
|
|
101
|
+
// For multi-user or higher-value sites, upgrade beyond a single shared secret:
|
|
102
|
+
// • Cloudflare Access (recommended): put this Worker behind Access and verify
|
|
103
|
+
// the "Cf-Access-Jwt-Assertion" header against your team's JWKS.
|
|
104
|
+
// • GitHub OAuth: verify the signed-in user has push access to the repo.
|
|
105
|
+
// Also add a Cloudflare rate-limiting rule on /api/* to blunt secret guessing.
|
|
106
|
+
async function authorizeRequest(request, env) {
|
|
107
|
+
const secret = String(env.AUTH_SECRET || "");
|
|
108
|
+
// Fail closed: a connector with no editor secret would let anyone who can load
|
|
109
|
+
// the site from an allowed origin publish to the repo. Refuse to act until an
|
|
110
|
+
// AUTH_SECRET is configured (set it with `wrangler secret put AUTH_SECRET`).
|
|
111
|
+
if (!secret) {
|
|
112
|
+
return { ok: false, status: 503, error: "This connector has no editor secret set. Configure the AUTH_SECRET secret on the Worker before editing." };
|
|
113
|
+
}
|
|
114
|
+
const provided = request.headers.get("x-charlescms-auth") || "";
|
|
115
|
+
if (!(await timingSafeEqual(provided, secret))) {
|
|
116
|
+
return { ok: false, status: 401, error: "Missing or invalid editor credentials." };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Constant-time secret comparison. Both inputs are hashed to a fixed-length
|
|
122
|
+
// digest first (so neither the length nor an early-mismatch position leaks
|
|
123
|
+
// through timing), then compared with a branchless XOR accumulator.
|
|
124
|
+
async function timingSafeEqual(a, b) {
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
const [da, db] = await Promise.all([
|
|
127
|
+
crypto.subtle.digest("SHA-256", encoder.encode(a)),
|
|
128
|
+
crypto.subtle.digest("SHA-256", encoder.encode(b))
|
|
129
|
+
]);
|
|
130
|
+
const va = new Uint8Array(da);
|
|
131
|
+
const vb = new Uint8Array(db);
|
|
132
|
+
let diff = 0;
|
|
133
|
+
for (let i = 0; i < va.length; i++) diff |= va[i] ^ vb[i];
|
|
134
|
+
return diff === 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function createGitHubApi(env, repo) {
|
|
138
|
+
const appJwt = await createAppJwt(env);
|
|
139
|
+
const installationId = env.GITHUB_INSTALLATION_ID || await findInstallationId(appJwt, repo);
|
|
140
|
+
const token = await installationToken(env, appJwt, installationId);
|
|
141
|
+
|
|
142
|
+
const githubFetch = async (path, init = {}) => {
|
|
143
|
+
const response = await fetch(`${githubApiBase}${path}`, {
|
|
144
|
+
...init,
|
|
145
|
+
headers: {
|
|
146
|
+
...githubHeaders(token),
|
|
147
|
+
...init.headers
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) throw await githubError(response, "GitHub request failed");
|
|
151
|
+
return response;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getFile = async (targetRepo, path, ref) => {
|
|
155
|
+
const response = await fetch(`${githubApiBase}/repos/${targetRepo}/contents/${encodeUriPath(path)}?ref=${encodeURIComponent(ref)}`, {
|
|
156
|
+
headers: githubHeaders(token)
|
|
157
|
+
});
|
|
158
|
+
if (response.status === 404) return null;
|
|
159
|
+
if (!response.ok) throw await githubError(response, "GitHub file read failed");
|
|
160
|
+
const data = await response.json();
|
|
161
|
+
if (data.type !== "file" || !data.content || !data.sha) {
|
|
162
|
+
throw new ConnectorHttpError("GitHub response did not contain an editable file.", 400);
|
|
163
|
+
}
|
|
164
|
+
return { sha: data.sha, source: decodeBase64(data.content) };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const putFile = async (targetRepo, path, source, options) => putContent(targetRepo, path, encodeBase64(source), options);
|
|
168
|
+
const putBase64 = async (targetRepo, path, base64, options) => putContent(targetRepo, path, String(base64).replace(/\s/g, ""), options);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
repo: async (targetRepo) => (await githubFetch(`/repos/${targetRepo}`)).json(),
|
|
172
|
+
getFile,
|
|
173
|
+
putFile,
|
|
174
|
+
putBase64,
|
|
175
|
+
listCommits: async (targetRepo, branch, limit = 20) => {
|
|
176
|
+
const capped = Math.max(1, Math.min(Number(limit) || 20, 50));
|
|
177
|
+
const response = await githubFetch(`/repos/${targetRepo}/commits?sha=${encodeURIComponent(branch)}&per_page=${capped}`);
|
|
178
|
+
const commits = await response.json();
|
|
179
|
+
return commits.map((item) => ({
|
|
180
|
+
sha: item.sha,
|
|
181
|
+
url: item.html_url || "",
|
|
182
|
+
message: item.commit?.message || "",
|
|
183
|
+
author: item.commit?.author?.name || "",
|
|
184
|
+
committedAt: item.commit?.committer?.date || ""
|
|
185
|
+
}));
|
|
186
|
+
},
|
|
187
|
+
revertCommit: async (targetRepo, branch, sha) => {
|
|
188
|
+
if (!/^[a-f0-9]{7,40}$/i.test(sha)) throw new ConnectorHttpError("A valid commit sha is required.", 400);
|
|
189
|
+
const detail = await (await githubFetch(`/repos/${targetRepo}/commits/${encodeURIComponent(sha)}`)).json();
|
|
190
|
+
const parentSha = detail.parents?.[0]?.sha;
|
|
191
|
+
if (!parentSha) throw new ConnectorHttpError("Cannot revert a commit without a parent.", 400);
|
|
192
|
+
const commits = [];
|
|
193
|
+
const skipped = [];
|
|
194
|
+
for (const file of detail.files || []) {
|
|
195
|
+
// A commit may also touch files outside CharlesCMS-managed source/public
|
|
196
|
+
// (a workflow, a README, a config). Skip those instead of failing the
|
|
197
|
+
// whole revert, and report them so the editor knows the undo was partial.
|
|
198
|
+
const path = cmsManagedPath(file.filename);
|
|
199
|
+
if (!path) {
|
|
200
|
+
skipped.push(file.filename);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const current = await getFile(targetRepo, path, branch);
|
|
204
|
+
const parent = await getFile(targetRepo, path, parentSha);
|
|
205
|
+
if (!parent && current) {
|
|
206
|
+
commits.push(await deleteFile(targetRepo, path, current.sha, `Revert ${sha.slice(0, 7)}: remove ${path}`, branch));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!parent || current?.source === parent.source) continue;
|
|
210
|
+
commits.push(await putContent(targetRepo, path, encodeBase64(parent.source), { branch, sha: current?.sha, message: `Revert ${sha.slice(0, 7)}: restore ${path}` }));
|
|
211
|
+
}
|
|
212
|
+
return { reverted: sha, parent: parentSha, branch, commits, skipped };
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
async function putContent(targetRepo, path, content, { branch, sha, message }) {
|
|
217
|
+
const body = { branch, message, content };
|
|
218
|
+
if (sha) body.sha = sha;
|
|
219
|
+
const response = await githubFetch(`/repos/${targetRepo}/contents/${encodeUriPath(path)}`, {
|
|
220
|
+
method: "PUT",
|
|
221
|
+
body: JSON.stringify(body)
|
|
222
|
+
});
|
|
223
|
+
const data = await response.json();
|
|
224
|
+
return { sha: data.commit?.sha || "", url: data.commit?.html_url || "", branch };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function deleteFile(targetRepo, path, sha, message, branch) {
|
|
228
|
+
const response = await githubFetch(`/repos/${targetRepo}/contents/${encodeUriPath(path)}`, {
|
|
229
|
+
method: "DELETE",
|
|
230
|
+
body: JSON.stringify({ branch, message, sha })
|
|
231
|
+
});
|
|
232
|
+
const data = await response.json();
|
|
233
|
+
return { sha: data.commit?.sha || "", url: data.commit?.html_url || "", branch };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function findInstallationId(appJwt, repo) {
|
|
238
|
+
const response = await fetch(`${githubApiBase}/repos/${repo}/installation`, {
|
|
239
|
+
headers: githubHeaders(appJwt)
|
|
240
|
+
});
|
|
241
|
+
if (response.status === 404) {
|
|
242
|
+
throw new ConnectorHttpError("GitHub App is not installed on this repository.", 404);
|
|
243
|
+
}
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
const fallback = await findInstallationIdFromApp(appJwt);
|
|
246
|
+
if (fallback.id) return fallback.id;
|
|
247
|
+
const lookupError = await githubError(response, "GitHub installation lookup failed");
|
|
248
|
+
lookupError.message += fallback.message ? `; app installations ${fallback.message}` : "";
|
|
249
|
+
throw lookupError;
|
|
250
|
+
}
|
|
251
|
+
const data = await response.json();
|
|
252
|
+
return data.id;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function findInstallationIdFromApp(appJwt) {
|
|
256
|
+
const response = await fetch(`${githubApiBase}/app/installations`, {
|
|
257
|
+
headers: githubHeaders(appJwt)
|
|
258
|
+
});
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
const data = await response.json().catch(() => ({}));
|
|
261
|
+
return { id: null, message: `lookup failed (${response.status})${data.message ? `: ${data.message}` : ""}` };
|
|
262
|
+
}
|
|
263
|
+
const installations = await response.json().catch(() => []);
|
|
264
|
+
if (!Array.isArray(installations)) return { id: null, message: "response was not a list" };
|
|
265
|
+
if (installations.length !== 1) return { id: null, message: `found ${installations.length} installations` };
|
|
266
|
+
return { id: installations[0]?.id || null, message: "" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function installationToken(env, appJwt, installationId) {
|
|
270
|
+
const key = String(installationId);
|
|
271
|
+
const cached = tokenCache.get(key);
|
|
272
|
+
if (cached && cached.expiresAt > Date.now() + 60000) return cached.token;
|
|
273
|
+
const response = await fetch(`${githubApiBase}/app/installations/${installationId}/access_tokens`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: githubHeaders(appJwt),
|
|
276
|
+
body: JSON.stringify({})
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
throw await githubError(
|
|
280
|
+
response,
|
|
281
|
+
`GitHub token exchange failed for app ${env.GITHUB_APP_ID || "(missing)"} installation ${installationId || "(missing)"}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const data = await response.json();
|
|
285
|
+
tokenCache.set(key, { token: data.token, expiresAt: Date.parse(data.expires_at) || Date.now() + 30 * 60 * 1000 });
|
|
286
|
+
return data.token;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function githubError(response, label) {
|
|
290
|
+
const data = await response.json().catch(() => ({}));
|
|
291
|
+
const detail = data.message ? `: ${data.message}` : "";
|
|
292
|
+
return new ConnectorHttpError(`${label} (${response.status})${detail}`, response.status);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function createAppJwt(env) {
|
|
296
|
+
const appId = env.GITHUB_APP_ID;
|
|
297
|
+
const issuer = env.GITHUB_CLIENT_ID || appId;
|
|
298
|
+
const privateKey = env.GITHUB_PRIVATE_KEY;
|
|
299
|
+
if (!appId || !privateKey) throw new ConnectorHttpError("GitHub App secrets are not configured.", 500);
|
|
300
|
+
const now = Math.floor(Date.now() / 1000);
|
|
301
|
+
const header = base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
302
|
+
const payload = base64Url(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(issuer) }));
|
|
303
|
+
const unsigned = `${header}.${payload}`;
|
|
304
|
+
const key = await crypto.subtle.importKey(
|
|
305
|
+
"pkcs8",
|
|
306
|
+
pemToArrayBuffer(privateKey),
|
|
307
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
308
|
+
false,
|
|
309
|
+
["sign"]
|
|
310
|
+
);
|
|
311
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(unsigned));
|
|
312
|
+
return `${unsigned}.${base64UrlBytes(new Uint8Array(signature))}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function corsHeaders(origin, env) {
|
|
316
|
+
const headers = {
|
|
317
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
318
|
+
"access-control-allow-headers": "Content-Type, X-CharlesCMS-Auth",
|
|
319
|
+
"cache-control": "no-store"
|
|
320
|
+
};
|
|
321
|
+
if (isAllowedOrigin(origin, env)) headers["access-control-allow-origin"] = origin;
|
|
322
|
+
return headers;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isAllowedOrigin(origin, env) {
|
|
326
|
+
if (!origin) return true;
|
|
327
|
+
return listEnv(env.ALLOWED_ORIGINS).includes(origin);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isAllowedRepo(repo, env) {
|
|
331
|
+
return listEnv(env.ALLOWED_REPOS).includes(repo);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function listEnv(value) {
|
|
335
|
+
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeRepo(value) {
|
|
339
|
+
const repo = String(value || "").trim();
|
|
340
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
341
|
+
throw new ConnectorHttpError("Repository must be in owner/name form.", 400);
|
|
342
|
+
}
|
|
343
|
+
return repo;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function safePath(value) {
|
|
347
|
+
const path = String(value || "").replace(/^\/+/, "");
|
|
348
|
+
if (!path || path.split(/[\\/]/).includes("..")) {
|
|
349
|
+
throw new ConnectorHttpError("Path points outside the repository.", 400);
|
|
350
|
+
}
|
|
351
|
+
return path;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function safeCmsPath(value) {
|
|
355
|
+
const path = safePath(value);
|
|
356
|
+
if (isSourcePath(path) || isPublicPath(path)) return path;
|
|
357
|
+
throw new ConnectorHttpError("Path is outside CharlesCMS-managed source and public files.", 400);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Same allow-list as safeCmsPath, but returns null instead of throwing — used by
|
|
361
|
+
// revert, which must skip unmanaged or unsafe paths rather than abort the undo.
|
|
362
|
+
function cmsManagedPath(value) {
|
|
363
|
+
try {
|
|
364
|
+
return safeCmsPath(value);
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function safeSourcePath(value) {
|
|
371
|
+
const path = safePath(value);
|
|
372
|
+
if (isSourcePath(path)) return path;
|
|
373
|
+
throw new ConnectorHttpError("Source writes are limited to files below a src directory.", 400);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function safePublicPath(value) {
|
|
377
|
+
const path = safePath(value);
|
|
378
|
+
if (isPublicPath(path)) return path;
|
|
379
|
+
throw new ConnectorHttpError("Media writes are limited to files below a public directory.", 400);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isSourcePath(path) {
|
|
383
|
+
const parts = path.split("/");
|
|
384
|
+
const srcIndex = parts.lastIndexOf("src");
|
|
385
|
+
const extension = parts.at(-1)?.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] || "";
|
|
386
|
+
return srcIndex >= 0
|
|
387
|
+
&& srcIndex < parts.length - 1
|
|
388
|
+
&& [".astro", ".md", ".mdx", ".json", ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"].includes(extension);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isPublicPath(path) {
|
|
392
|
+
const parts = path.split("/");
|
|
393
|
+
const publicIndex = parts.lastIndexOf("public");
|
|
394
|
+
return publicIndex >= 0 && publicIndex < parts.length - 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isNewUploadPath(path) {
|
|
398
|
+
return /(?:^|\/)public\/uploads\/[A-Za-z0-9._-]+$/.test(path);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function requiredSha(value) {
|
|
402
|
+
const sha = String(value || "").trim();
|
|
403
|
+
if (!/^[a-f0-9]{7,64}$/i.test(sha)) {
|
|
404
|
+
throw new ConnectorHttpError("An existing file SHA is required for this write.", 400);
|
|
405
|
+
}
|
|
406
|
+
return sha;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function githubHeaders(token) {
|
|
410
|
+
return {
|
|
411
|
+
accept: "application/vnd.github+json",
|
|
412
|
+
authorization: `Bearer ${token}`,
|
|
413
|
+
"content-type": "application/json",
|
|
414
|
+
"user-agent": "charlescms-connector",
|
|
415
|
+
"x-github-api-version": "2022-11-28"
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function encodeUriPath(path) {
|
|
420
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function encodeBase64(value) {
|
|
424
|
+
return base64FromBytes(new TextEncoder().encode(value));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function decodeBase64(value) {
|
|
428
|
+
const binary = atob(String(value).replace(/\s/g, ""));
|
|
429
|
+
const bytes = new Uint8Array(binary.length);
|
|
430
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
431
|
+
return new TextDecoder().decode(bytes);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function pemToArrayBuffer(pem) {
|
|
435
|
+
const normalized = String(pem).replace(/\\n/g, "\n");
|
|
436
|
+
const isPkcs1 = normalized.includes("BEGIN RSA PRIVATE KEY");
|
|
437
|
+
const clean = normalized.replace(/-----BEGIN (RSA )?PRIVATE KEY-----|-----END (RSA )?PRIVATE KEY-----|\s/g, "");
|
|
438
|
+
const binary = atob(clean);
|
|
439
|
+
const bytes = new Uint8Array(binary.length);
|
|
440
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
441
|
+
return isPkcs1 ? wrapRsaPkcs1AsPkcs8(bytes).buffer : bytes.buffer;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function wrapRsaPkcs1AsPkcs8(pkcs1) {
|
|
445
|
+
const rsaAlgorithmIdentifier = new Uint8Array([
|
|
446
|
+
0x30, 0x0d,
|
|
447
|
+
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
|
448
|
+
0x05, 0x00
|
|
449
|
+
]);
|
|
450
|
+
const version = new Uint8Array([0x02, 0x01, 0x00]);
|
|
451
|
+
const privateKey = concatDer(new Uint8Array([0x04]), derLength(pkcs1.length), pkcs1);
|
|
452
|
+
const body = concatDer(version, rsaAlgorithmIdentifier, privateKey);
|
|
453
|
+
return concatDer(new Uint8Array([0x30]), derLength(body.length), body);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function derLength(length) {
|
|
457
|
+
if (length < 128) return new Uint8Array([length]);
|
|
458
|
+
const bytes = [];
|
|
459
|
+
let value = length;
|
|
460
|
+
while (value > 0) {
|
|
461
|
+
bytes.unshift(value & 0xff);
|
|
462
|
+
value >>= 8;
|
|
463
|
+
}
|
|
464
|
+
return new Uint8Array([0x80 | bytes.length, ...bytes]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function concatDer(...parts) {
|
|
468
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
469
|
+
const result = new Uint8Array(total);
|
|
470
|
+
let offset = 0;
|
|
471
|
+
for (const part of parts) {
|
|
472
|
+
result.set(part, offset);
|
|
473
|
+
offset += part.length;
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function base64Url(value) {
|
|
479
|
+
return base64UrlBytes(new TextEncoder().encode(value));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function base64UrlBytes(bytes) {
|
|
483
|
+
return base64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function base64FromBytes(bytes) {
|
|
487
|
+
let binary = "";
|
|
488
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
489
|
+
return btoa(binary);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function json(value, status = 200, headers = {}) {
|
|
493
|
+
return new Response(JSON.stringify(value), {
|
|
494
|
+
status,
|
|
495
|
+
headers: { "content-type": "application/json", ...headers }
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
class ConnectorHttpError extends Error {
|
|
500
|
+
constructor(message, status = 500) {
|
|
501
|
+
super(message);
|
|
502
|
+
this.name = "ConnectorHttpError";
|
|
503
|
+
this.status = status;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name = "charlescms-connector"
|
|
2
|
+
main = "./worker.js"
|
|
3
|
+
compatibility_date = "2026-06-04"
|
|
4
|
+
|
|
5
|
+
[vars]
|
|
6
|
+
# Comma-separated lists.
|
|
7
|
+
ALLOWED_ORIGINS = "http://localhost:4321"
|
|
8
|
+
ALLOWED_REPOS = "owner/site"
|
|
9
|
+
DEFAULT_BRANCH = "main"
|
|
10
|
+
|
|
11
|
+
# Set these with wrangler secret put:
|
|
12
|
+
# GITHUB_APP_ID
|
|
13
|
+
# GITHUB_PRIVATE_KEY
|
|
14
|
+
# Optional: GITHUB_INSTALLATION_ID
|
|
15
|
+
# Required editor password: AUTH_SECRET
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@charlescms/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Source-native live CMS editor for Astro sites.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Charles-CMS/astro.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Charles-CMS/astro/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Charles-CMS/astro#readme",
|
|
14
|
+
"main": "./src/index.js",
|
|
15
|
+
"bin": {
|
|
16
|
+
"charlescms": "./scripts/setup.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js",
|
|
20
|
+
"./src/*": "./src/*"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.12.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node ./scripts/check-package.js && node ./scripts/check-licenses.js",
|
|
32
|
+
"check:licenses": "node ./scripts/check-licenses.js",
|
|
33
|
+
"prepublishOnly": "npm run build",
|
|
34
|
+
"test": "node --test",
|
|
35
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=75 --test-coverage-branches=70 --test-coverage-functions=75",
|
|
36
|
+
"test:e2e": "node ./scripts/e2e.js",
|
|
37
|
+
"test:windows-smoke": "node ./scripts/windows-smoke.js",
|
|
38
|
+
"test:browser": "node ./scripts/browser-fixture-flow.js",
|
|
39
|
+
"test:live": "node ./scripts/live-smoke.js",
|
|
40
|
+
"update:vendored-site": "node ./scripts/update-vendored-site.js"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"astro": ">=6.2.0 <7"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"playwright": "^1.60.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@astrojs/compiler": "^4.0.0",
|
|
50
|
+
"@babel/parser": "^7.29.7",
|
|
51
|
+
"@tiptap/core": "^3.26.0",
|
|
52
|
+
"@tiptap/extension-bold": "^3.26.0",
|
|
53
|
+
"@tiptap/extension-code": "^3.26.0",
|
|
54
|
+
"@tiptap/extension-document": "^3.26.0",
|
|
55
|
+
"@tiptap/extension-hard-break": "^3.26.0",
|
|
56
|
+
"@tiptap/extension-italic": "^3.26.0",
|
|
57
|
+
"@tiptap/extension-link": "^3.26.0",
|
|
58
|
+
"@tiptap/extension-text": "^3.26.0",
|
|
59
|
+
"@tiptap/pm": "^3.26.0",
|
|
60
|
+
"js-yaml": "^4.2.0",
|
|
61
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
62
|
+
"mdast-util-gfm": "^3.1.0",
|
|
63
|
+
"mdast-util-to-markdown": "^2.1.2",
|
|
64
|
+
"mdast-util-to-string": "^4.0.0",
|
|
65
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
66
|
+
"parse5": "^7.3.0",
|
|
67
|
+
"vite": "^7.3.5"
|
|
68
|
+
},
|
|
69
|
+
"overrides": {
|
|
70
|
+
"esbuild": "^0.28.1"
|
|
71
|
+
},
|
|
72
|
+
"files": [
|
|
73
|
+
"connector",
|
|
74
|
+
"src",
|
|
75
|
+
"scripts/setup.js",
|
|
76
|
+
"scripts/update-vendored-site.js",
|
|
77
|
+
"scripts/check-package.js",
|
|
78
|
+
"scripts/check-licenses.js",
|
|
79
|
+
"THIRD_PARTY_NOTICES.md",
|
|
80
|
+
"LICENSE",
|
|
81
|
+
"README.md",
|
|
82
|
+
"SECURITY.md",
|
|
83
|
+
"package.json"
|
|
84
|
+
],
|
|
85
|
+
"keywords": [
|
|
86
|
+
"astro",
|
|
87
|
+
"cms",
|
|
88
|
+
"inline-editing",
|
|
89
|
+
"visual-cms"
|
|
90
|
+
],
|
|
91
|
+
"license": "MIT"
|
|
92
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
let lock;
|
|
4
|
+
try {
|
|
5
|
+
lock = JSON.parse(await readFile(new URL("../package-lock.json", import.meta.url), "utf8"));
|
|
6
|
+
} catch (error) {
|
|
7
|
+
if (error?.code === "ENOENT") {
|
|
8
|
+
console.log("Dependency licence check skipped: package-lock.json is not part of the published package.");
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
const allowed = new Set([
|
|
14
|
+
"0BSD",
|
|
15
|
+
"Apache-2.0",
|
|
16
|
+
"BlueOak-1.0.0",
|
|
17
|
+
"BSD-2-Clause",
|
|
18
|
+
"BSD-3-Clause",
|
|
19
|
+
"CC0-1.0",
|
|
20
|
+
"ISC",
|
|
21
|
+
"LGPL-3.0-or-later",
|
|
22
|
+
"MIT",
|
|
23
|
+
"Python-2.0"
|
|
24
|
+
]);
|
|
25
|
+
const failures = [];
|
|
26
|
+
const reviewed = [];
|
|
27
|
+
|
|
28
|
+
for (const [path, metadata] of Object.entries(lock.packages || {})) {
|
|
29
|
+
if (!path || metadata.dev) continue;
|
|
30
|
+
const expression = String(metadata.license || "").trim();
|
|
31
|
+
const identifiers = expression.split(/\s+(?:AND|OR)\s+/).map((value) => value.replace(/[()]/g, ""));
|
|
32
|
+
if (!expression || identifiers.some((identifier) => !allowed.has(identifier))) {
|
|
33
|
+
failures.push(`${path}: ${expression || "missing licence metadata"}`);
|
|
34
|
+
}
|
|
35
|
+
if (identifiers.includes("LGPL-3.0-or-later") || identifiers.includes("Python-2.0")) {
|
|
36
|
+
reviewed.push(`${path}: ${expression}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (failures.length) {
|
|
41
|
+
console.error(`Unreviewed production dependency licences:\n${failures.join("\n")}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`Dependency licence check passed (${reviewed.length} explicitly reviewed package entries).`);
|