@stelstone/server 0.30.1 → 0.32.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 +1 -1
- package/src/adapters/basic-auth.mjs +9 -1
- package/src/adapters/fs-json-content.mjs +8 -3
- package/src/adapters/github-content.mjs +22 -2
- package/src/adapters/github-oauth.mjs +1 -1
- package/src/core/config-schema.mjs +6 -0
- package/src/routes.mjs +19 -7
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -86,7 +86,15 @@ export function createBasicAuth({
|
|
|
86
86
|
const p = decoded.slice(colon + 1);
|
|
87
87
|
const entry = findUser(u, p);
|
|
88
88
|
if (!entry) return null;
|
|
89
|
-
|
|
89
|
+
// `email` is optional and deliberately not derived from the username: a
|
|
90
|
+
// made-up address in a commit looks like a real one, and git history is
|
|
91
|
+
// read as evidence. Without it the commit falls back to the token owner.
|
|
92
|
+
return {
|
|
93
|
+
login: entry.user,
|
|
94
|
+
name: entry.name || entry.user,
|
|
95
|
+
email: entry.email || null,
|
|
96
|
+
role: entry.role || "editor",
|
|
97
|
+
};
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
return {
|
|
@@ -49,8 +49,9 @@ export function createFsJsonContent({
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function git(cmd) {
|
|
53
|
-
|
|
52
|
+
function git(cmd, { trim = true } = {}) {
|
|
53
|
+
const out = execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" });
|
|
54
|
+
return trim ? out.trim() : out;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function pagePath(collection, file) {
|
|
@@ -206,7 +207,11 @@ export function createFsJsonContent({
|
|
|
206
207
|
capabilities: { deferredPublish: true, perEntryPublish: true },
|
|
207
208
|
|
|
208
209
|
async pendingChanges() {
|
|
209
|
-
|
|
210
|
+
// Untrimmed: a modified file's line starts with a space (" M path") and
|
|
211
|
+
// trimming ate it on the first line, which then lost the first letter
|
|
212
|
+
// of its path and was never attributed to its collection — so the
|
|
213
|
+
// first pending entry in a list never got its badge.
|
|
214
|
+
const status = git(`status --porcelain ${PATHS_ARG}`, { trim: false });
|
|
210
215
|
const lines = status.split("\n").filter(Boolean);
|
|
211
216
|
// Porcelain line: "XY path" (rename: "XY old -> new"). Only entries under
|
|
212
217
|
// pagesDir are attributed to a collection/file; other publishPaths (e.g.
|
|
@@ -52,6 +52,20 @@ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue
|
|
|
52
52
|
* @param {Object} [opts.list] Listing strategy config (see above)
|
|
53
53
|
* @returns {import('./types.mjs').ContentAdapter}
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* The `author` field for a git-data commit, or undefined.
|
|
57
|
+
*
|
|
58
|
+
* GitHub attributes a commit to the token owner when this is absent, which is
|
|
59
|
+
* what made every entry in the CMS's history read as the same person no
|
|
60
|
+
* matter who published it. A user with no configured email falls back to that
|
|
61
|
+
* rather than getting an invented address: history is read as evidence, and a
|
|
62
|
+
* plausible-looking address nobody owns is worse than an honest default.
|
|
63
|
+
*/
|
|
64
|
+
function commitAuthor(actor) {
|
|
65
|
+
if (!actor?.email) return undefined;
|
|
66
|
+
return { name: actor.name || actor.login, email: actor.email };
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
export function createGitHubContent({ token, owner, repo, branch, draftBranch, pagesDir, commitMessage, list = {}, collections = {} }) {
|
|
56
70
|
const listConfig = {
|
|
57
71
|
strategy: "index",
|
|
@@ -323,14 +337,19 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
323
337
|
* write time — which is what this did before — discarded that
|
|
324
338
|
* guarantee and silently overwrote a concurrent edit.
|
|
325
339
|
*/
|
|
326
|
-
async writePage(collection, file, data, { expectedVersion } = {}) {
|
|
340
|
+
async writePage(collection, file, data, { expectedVersion, actor } = {}) {
|
|
327
341
|
await ensureDraftBranch();
|
|
328
342
|
const path = contentPath(collection, sanitize(file));
|
|
329
343
|
const sha = expectedVersion ?? (await getFileSha(path));
|
|
330
344
|
let result;
|
|
331
345
|
try {
|
|
346
|
+
// The history screen lists commits on the draft branch, so this write
|
|
347
|
+
// is the one that answers "who edited this" — attributing it matters
|
|
348
|
+
// more here than on the publish commit.
|
|
349
|
+
const author = commitAuthor(actor);
|
|
332
350
|
result = await apiPut(`/contents/${path}`, {
|
|
333
351
|
message: commitMsg(commitMessage),
|
|
352
|
+
...(author ? { author } : {}),
|
|
334
353
|
content: encodeContent(data),
|
|
335
354
|
branch: workBranch,
|
|
336
355
|
...(sha ? { sha } : {}),
|
|
@@ -447,7 +466,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
447
466
|
* @param {string} [message]
|
|
448
467
|
* @param {{ entries?: {collection: string, file: string}[], target?: string }} [opts]
|
|
449
468
|
*/
|
|
450
|
-
async publish(message, { entries, target } = {}) {
|
|
469
|
+
async publish(message, { entries, target, actor } = {}) {
|
|
451
470
|
const toBranch = target || branch;
|
|
452
471
|
if (!draftMode) {
|
|
453
472
|
// Writes are committed instantly; trigger is external (Netlify webhook on push)
|
|
@@ -490,6 +509,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
490
509
|
message: msg,
|
|
491
510
|
tree: newTree.sha,
|
|
492
511
|
parents: [baseCommitSha],
|
|
512
|
+
author: commitAuthor(actor),
|
|
493
513
|
});
|
|
494
514
|
await apiPatch(`/git/refs/heads/${toBranch}`, { sha: newCommit.sha });
|
|
495
515
|
const shortSha = newCommit.sha.slice(0, 7);
|
|
@@ -116,7 +116,7 @@ export function createGitHubOAuth({
|
|
|
116
116
|
if (!authHeader.startsWith("Bearer ")) return null;
|
|
117
117
|
const claims = verifyToken(authHeader.slice(7));
|
|
118
118
|
if (!claims || claims.type !== "session") return null;
|
|
119
|
-
return { login: claims.sub, role: getRole(claims.sub), name: claims.name };
|
|
119
|
+
return { login: claims.sub, role: getRole(claims.sub), name: claims.name, email: claims.email ?? null };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function issueSessionToken(login, name) {
|
|
@@ -156,6 +156,12 @@ function checkAuth(config, report, { getSecret }) {
|
|
|
156
156
|
`${entry.passEnv} is not set — user "${entry.user}" is DISABLED (check for a typo in the variable name)`,
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
|
+
// Optional, and only useful if it is real: it becomes the author of
|
|
160
|
+
// the commits this user makes. A malformed one would attribute their
|
|
161
|
+
// work to nobody, so say so rather than write it.
|
|
162
|
+
if (entry.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(entry.email))) {
|
|
163
|
+
report.error(`auth.users[${i}].email`, "must be an email address — it becomes the git commit author");
|
|
164
|
+
}
|
|
159
165
|
});
|
|
160
166
|
} else if (!auth.passEnv && !auth.userEnv) {
|
|
161
167
|
report.warn("auth.users", "no users configured — the CMS will run without authentication");
|
package/src/routes.mjs
CHANGED
|
@@ -231,7 +231,7 @@ export const apiRoutes = [
|
|
|
231
231
|
method: "PUT",
|
|
232
232
|
path: "/api/collections/:collection/:file",
|
|
233
233
|
auth: "any",
|
|
234
|
-
handler: async ({ adapters, params, body, header }) => {
|
|
234
|
+
handler: async ({ adapters, params, body, header, user }) => {
|
|
235
235
|
const bodyErr = pageBodyError(body);
|
|
236
236
|
if (bodyErr) return badRequest(bodyErr);
|
|
237
237
|
|
|
@@ -239,7 +239,7 @@ export const apiRoutes = [
|
|
|
239
239
|
const expectedVersion = ifMatch ? ifMatch.replace(/^W\//, "").replace(/"/g, "") : undefined;
|
|
240
240
|
|
|
241
241
|
try {
|
|
242
|
-
await adapters.content.writePage(params.collection, params.file, body, { expectedVersion });
|
|
242
|
+
await adapters.content.writePage(params.collection, params.file, body, { expectedVersion, actor: user });
|
|
243
243
|
} catch (err) {
|
|
244
244
|
if (err.status === 412) {
|
|
245
245
|
return {
|
|
@@ -311,9 +311,20 @@ export const apiRoutes = [
|
|
|
311
311
|
if (!data) return notFound();
|
|
312
312
|
|
|
313
313
|
let url = null;
|
|
314
|
+
// Which publish target the preview shows, when the site can say: a
|
|
315
|
+
// draft previews on the staging site, a published entry on the live
|
|
316
|
+
// one. The admin needs this to tell whether the framed page is current
|
|
317
|
+
// or behind the saved draft.
|
|
318
|
+
let target = null;
|
|
314
319
|
if (typeof config.previewUrl === "function") {
|
|
315
320
|
try {
|
|
316
|
-
|
|
321
|
+
const out = config.previewUrl({ collection: params.collection, data });
|
|
322
|
+
if (out && typeof out === "object") {
|
|
323
|
+
url = out.url ?? null;
|
|
324
|
+
target = out.target ?? null;
|
|
325
|
+
} else {
|
|
326
|
+
url = out;
|
|
327
|
+
}
|
|
317
328
|
} catch (err) {
|
|
318
329
|
return { status: 500, json: { error: `previewUrl(): ${err.message}` } };
|
|
319
330
|
}
|
|
@@ -323,7 +334,7 @@ export const apiRoutes = [
|
|
|
323
334
|
.replace("{slug}", data.slug || "")
|
|
324
335
|
.replace("{lang}", data.lang || "");
|
|
325
336
|
}
|
|
326
|
-
return ok({ url });
|
|
337
|
+
return ok({ url, target });
|
|
327
338
|
},
|
|
328
339
|
},
|
|
329
340
|
|
|
@@ -585,11 +596,11 @@ export const apiRoutes = [
|
|
|
585
596
|
method: "POST",
|
|
586
597
|
path: "/api/publish",
|
|
587
598
|
auth: "admin",
|
|
588
|
-
handler: async ({ adapters, config, body }) => {
|
|
599
|
+
handler: async ({ adapters, config, body, user }) => {
|
|
589
600
|
const target = resolveTarget(config, body?.target);
|
|
590
601
|
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
591
602
|
try {
|
|
592
|
-
return ok(await adapters.content.publish(null, { target: target.branch }));
|
|
603
|
+
return ok(await adapters.content.publish(null, { target: target.branch, actor: user }));
|
|
593
604
|
} catch (err) {
|
|
594
605
|
return { status: 500, json: { ok: false, message: err.message } };
|
|
595
606
|
}
|
|
@@ -603,7 +614,7 @@ export const apiRoutes = [
|
|
|
603
614
|
method: "POST",
|
|
604
615
|
path: "/api/collections/:collection/:file/publish",
|
|
605
616
|
auth: "admin",
|
|
606
|
-
handler: async ({ adapters, params, config, body }) => {
|
|
617
|
+
handler: async ({ adapters, params, config, body, user }) => {
|
|
607
618
|
const target = resolveTarget(config, body?.target);
|
|
608
619
|
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
609
620
|
if (!adapters.content.capabilities?.perEntryPublish) {
|
|
@@ -617,6 +628,7 @@ export const apiRoutes = [
|
|
|
617
628
|
await adapters.content.publish(null, {
|
|
618
629
|
entries: [{ collection: params.collection, file: params.file }],
|
|
619
630
|
target: target.branch,
|
|
631
|
+
actor: user,
|
|
620
632
|
}),
|
|
621
633
|
);
|
|
622
634
|
} catch (err) {
|
package/src/version.mjs
CHANGED