@casomoltd/tooling 0.35.0 → 0.36.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/bin/buttondown.mjs +27 -10
- package/buttondown/engine.mjs +56 -11
- package/package.json +1 -1
package/bin/buttondown.mjs
CHANGED
|
@@ -12,19 +12,25 @@
|
|
|
12
12
|
* buttondown emit <dir> write each long field out as rendered
|
|
13
13
|
* buttondown draft <file.md> create or replace a draft
|
|
14
14
|
* buttondown upload <image> upload an image, print its URL
|
|
15
|
-
* buttondown api <METHOD> <path> [json]
|
|
16
|
-
* any call, for setting up a manual test
|
|
15
|
+
* buttondown api <METHOD> <path> [json] [--send]
|
|
16
|
+
* any call, for setting up a manual test;
|
|
17
|
+
* one that sends or schedules an email
|
|
18
|
+
* is refused without --send
|
|
17
19
|
*
|
|
18
20
|
* `diff`, `apply` and `check` exit 1 on drift, a refusal or a problem, so a
|
|
19
21
|
* script can gate on them.
|
|
20
22
|
*/
|
|
21
23
|
import {
|
|
22
|
-
apply, assertApiPath, client, draft, drift, emit,
|
|
23
|
-
runCheck, upload, writeTokens,
|
|
24
|
+
apply, assertApiPath, assertNotSending, client, draft, drift, emit,
|
|
25
|
+
loadSettings, readKey, runCheck, upload, writeTokens,
|
|
24
26
|
} from "../buttondown/engine.mjs";
|
|
25
27
|
|
|
28
|
+
/** The one flag: without it, `api` refuses any call that could send an
|
|
29
|
+
* email, and no other command takes it. */
|
|
30
|
+
const SEND_FLAG = "--send";
|
|
31
|
+
|
|
26
32
|
const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
|
|
27
|
-
+
|
|
33
|
+
+ `draft <file.md>|upload <image>|api <METHOD> <path> [json] [${SEND_FLAG}]]`;
|
|
28
34
|
|
|
29
35
|
/** Each command with the fewest and most arguments it takes. */
|
|
30
36
|
const ARGS = {
|
|
@@ -32,9 +38,13 @@ const ARGS = {
|
|
|
32
38
|
emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
|
|
33
39
|
};
|
|
34
40
|
|
|
35
|
-
const
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
const send = argv.includes(SEND_FLAG);
|
|
43
|
+
const [command = "diff", ...args] = argv.filter((a) => a !== SEND_FLAG);
|
|
36
44
|
const [fewest, most] = ARGS[command] ?? [];
|
|
37
|
-
|
|
45
|
+
const badUsage = fewest === undefined || args.length < fewest
|
|
46
|
+
|| args.length > most || (send && command !== "api");
|
|
47
|
+
if (badUsage) {
|
|
38
48
|
console.error(USAGE);
|
|
39
49
|
process.exit(2);
|
|
40
50
|
}
|
|
@@ -85,9 +95,16 @@ switch (command) {
|
|
|
85
95
|
case "api": {
|
|
86
96
|
const [method, url, body] = args;
|
|
87
97
|
assertApiPath(settings, method, url);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
const payload = body && JSON.parse(body);
|
|
99
|
+
if (!send) {
|
|
100
|
+
try {
|
|
101
|
+
assertNotSending(method, url, payload);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`buttondown: ${e.message}; add ${SEND_FLAG} to send it`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const res = await (await api()).call(method.toUpperCase(), url, payload);
|
|
91
108
|
console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
|
|
92
109
|
process.exit(res.ok ? 0 : 1);
|
|
93
110
|
}
|
package/buttondown/engine.mjs
CHANGED
|
@@ -87,6 +87,19 @@ const API_PATH = new URL(API).pathname;
|
|
|
87
87
|
/** The status of an email not yet sent. */
|
|
88
88
|
const DRAFT = "draft";
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The api routes that can mail someone, judged on a `requestPath`. A write
|
|
92
|
+
* to the emails collection or to one email may carry a status that sends
|
|
93
|
+
* it, so it passes only as a draft; any action on an email (publish,
|
|
94
|
+
* send-draft and whatever Buttondown adds) or on a subscriber (a reminder,
|
|
95
|
+
* an email to one reader) is refused outright, so a new route fails closed.
|
|
96
|
+
* Creating an unactivated subscriber also mails a confirmation, and stays
|
|
97
|
+
* allowed: it is how a manual test stands in for a reader signing up, to an
|
|
98
|
+
* address the tester owns.
|
|
99
|
+
*/
|
|
100
|
+
const EMAIL_WRITE = new RegExp(`^${API_PATH}/emails(/[^/]+)?/?$`);
|
|
101
|
+
const SENDING_ACTION = new RegExp(`^${API_PATH}/(emails|subscribers)/[^/]+/.`);
|
|
102
|
+
|
|
90
103
|
const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
91
104
|
|
|
92
105
|
/** The Buttondown fields holding the two skins. */
|
|
@@ -387,7 +400,7 @@ export async function readKey(env = process.env) {
|
|
|
387
400
|
export function client(settings, key) {
|
|
388
401
|
const {newsletter} = settings;
|
|
389
402
|
async function call(method, url, body, {form} = {}) {
|
|
390
|
-
const res = await fetch(
|
|
403
|
+
const res = await fetch(requestUrl(url), {
|
|
391
404
|
method,
|
|
392
405
|
headers: {
|
|
393
406
|
Authorization: `Token ${key}`,
|
|
@@ -445,25 +458,37 @@ export function client(settings, key) {
|
|
|
445
458
|
|
|
446
459
|
/**
|
|
447
460
|
* Refuses an `api` call that writes newsletter settings anywhere but this
|
|
448
|
-
* newsletter
|
|
449
|
-
* normalised, so `/%6eewsletters/<other>`, `/./newsletters/<other>` and
|
|
450
|
-
* `<own>/../<other>` are seen for what they are. A path still encoded after
|
|
451
|
-
* one decoding is refused outright rather than guessed at.
|
|
461
|
+
* newsletter, judged on the path the server routes (`requestPath`).
|
|
452
462
|
*/
|
|
453
463
|
export function assertApiPath(settings, method, url) {
|
|
454
464
|
if (method.toUpperCase() === "GET") return;
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
throw new Error(`api refuses an encoded path: ${url}`);
|
|
458
|
-
}
|
|
459
|
-
const {pathname} = new URL(decoded, API);
|
|
460
|
-
const own = API_PATH + newsletterPath(settings.newsletter);
|
|
465
|
+
const pathname = requestPath(url);
|
|
466
|
+
const own = (API_PATH + newsletterPath(settings.newsletter)).toLowerCase();
|
|
461
467
|
if (pathname.startsWith(`${API_PATH}/newsletters`)
|
|
462
468
|
&& pathname !== own && !pathname.startsWith(`${own}/`)) {
|
|
463
469
|
throw new Error(`api writes under /newsletters only to ${own}`);
|
|
464
470
|
}
|
|
465
471
|
}
|
|
466
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Throws if an api call could send or schedule an email (`EMAIL_WRITE`,
|
|
475
|
+
* `SENDING_ACTION`). Sending reaches real inboxes and cannot be recalled, so
|
|
476
|
+
* the caller lets a call through only on a person's explicit say-so.
|
|
477
|
+
*/
|
|
478
|
+
export function assertNotSending(method, url, body) {
|
|
479
|
+
const cannotSend = ["GET", "DELETE"].includes(method.toUpperCase());
|
|
480
|
+
if (cannotSend) return;
|
|
481
|
+
const pathname = requestPath(url);
|
|
482
|
+
const status = body && typeof body === "object" ? body.status : undefined;
|
|
483
|
+
const creates = pathname.replace(/\/$/, "") === `${API_PATH}/emails`;
|
|
484
|
+
const draftOnly = status === DRAFT || (status === undefined && !creates);
|
|
485
|
+
if (SENDING_ACTION.test(pathname)
|
|
486
|
+
|| (EMAIL_WRITE.test(pathname) && !draftOnly)) {
|
|
487
|
+
throw new Error(`api refuses a call that could send an email: `
|
|
488
|
+
+ `${method.toUpperCase()} ${url}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
467
492
|
/** Whether a live value already says what the repo wants. */
|
|
468
493
|
export function same(field, got, want) {
|
|
469
494
|
if (IMAGE_FIELDS.has(field)) {
|
|
@@ -693,6 +718,26 @@ export function stripComments(text) {
|
|
|
693
718
|
}
|
|
694
719
|
}
|
|
695
720
|
|
|
721
|
+
/** The URL `call` fetches for an api path, and the one the guards judge. */
|
|
722
|
+
function requestUrl(url) {
|
|
723
|
+
return new URL(API + url);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* The path the server routes for an `api` url, from `requestUrl`,
|
|
728
|
+
* then percent-decoded, slash-collapsed and normalised, so
|
|
729
|
+
* `/%6eewsletters/<other>`, `//emails/<id>/publish`, `/./newsletters/<other>`
|
|
730
|
+
* and `<own>/../<other>` are seen for what they are. A path still encoded
|
|
731
|
+
* after one decoding is refused outright rather than guessed at.
|
|
732
|
+
*/
|
|
733
|
+
function requestPath(url) {
|
|
734
|
+
const decoded = decodeURIComponent(requestUrl(url).pathname);
|
|
735
|
+
if (decoded.includes("%")) {
|
|
736
|
+
throw new Error(`api refuses an encoded path: ${url}`);
|
|
737
|
+
}
|
|
738
|
+
return new URL(decoded.replace(/\/{2,}/g, "/"), API).pathname.toLowerCase();
|
|
739
|
+
}
|
|
740
|
+
|
|
696
741
|
/** Throws unless an image field holds an absolute URL, since Buttondown
|
|
697
742
|
* stores a relative one as given and the image then never loads. */
|
|
698
743
|
function assertAbsoluteUrl(field, value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|