pagecast 0.1.0 → 0.1.2
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/.codex/skills/publish-report/SKILL.md +15 -7
- package/feedback/worker.js +234 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/hooks/detect-report.mjs +8 -5
- package/plugin/skills/publish-report/SKILL.md +25 -16
- package/public/assets/{code-mirror-html-CwFouvGT.js → code-mirror-html-DFn3DH5h.js} +1 -1
- package/public/assets/dnd-vendor-BmyNMol4.js +5 -0
- package/public/assets/index-Ci9snOW8.css +1 -0
- package/public/assets/index-DThD-nOy.js +60 -0
- package/public/assets/motion-vendor-CqvjzA30.js +1 -0
- package/public/index.html +4 -4
- package/src/cli.js +49 -0
- package/src/markdown.js +0 -0
- package/src/server.js +402 -14
- package/public/assets/dnd-vendor-BtfBOykZ.js +0 -1
- package/public/assets/index-BTCh8CIt.js +0 -54
- package/public/assets/index-XOrnLHdD.css +0 -1
- package/public/assets/motion-vendor-CDgKLzlB.js +0 -9
package/src/server.js
CHANGED
|
@@ -26,6 +26,17 @@ export const DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
26
26
|
export const DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS = 60 * 1000;
|
|
27
27
|
export const CLOUDFLARE_OAUTH_SCOPES = ["account:read", "user:read", "pages:write"];
|
|
28
28
|
|
|
29
|
+
// Feedback provisioning deploys a Worker + KV, which the base publishing scopes
|
|
30
|
+
// don't permit. These elevate the grant only when the user opts into feedback,
|
|
31
|
+
// so publishing never has to request Workers/KV access up front.
|
|
32
|
+
export const FEEDBACK_OAUTH_SCOPES = [
|
|
33
|
+
"account:read",
|
|
34
|
+
"user:read",
|
|
35
|
+
"pages:write",
|
|
36
|
+
"workers_scripts:write",
|
|
37
|
+
"workers_kv:write"
|
|
38
|
+
];
|
|
39
|
+
|
|
29
40
|
const MIME_TYPES = new Map([
|
|
30
41
|
[".html", "text/html; charset=utf-8"],
|
|
31
42
|
[".htm", "text/html; charset=utf-8"],
|
|
@@ -228,6 +239,62 @@ function cleanCommandOutput(output) {
|
|
|
228
239
|
.trim();
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
// --- Feedback Worker provisioning: parse wrangler outputs --------------------
|
|
243
|
+
|
|
244
|
+
// A KV namespace id is a 32-char hex string. `wrangler kv namespace create`
|
|
245
|
+
// prints a TOML/JSON snippet containing `id = "<hex>"` / `"id": "<hex>"`.
|
|
246
|
+
export function parseKvNamespaceId(output) {
|
|
247
|
+
const text = stripAnsi(output || "");
|
|
248
|
+
const match = text.match(/(?:id\s*=\s*|"id"\s*:\s*)"([0-9a-f]{32})"/i);
|
|
249
|
+
return match ? match[1].toLowerCase() : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// `wrangler kv namespace list` prints a JSON array of { id, title }. Reuse an
|
|
253
|
+
// existing namespace by title so re-running setup is idempotent.
|
|
254
|
+
export function findKvNamespaceId(output, title) {
|
|
255
|
+
const text = stripAnsi(output || "");
|
|
256
|
+
try {
|
|
257
|
+
const start = text.indexOf("[");
|
|
258
|
+
const end = text.lastIndexOf("]");
|
|
259
|
+
if (start >= 0 && end > start) {
|
|
260
|
+
const list = JSON.parse(text.slice(start, end + 1));
|
|
261
|
+
const hit = list.find((entry) => entry && entry.title === title);
|
|
262
|
+
if (hit && /^[0-9a-f]{32}$/i.test(hit.id || "")) {
|
|
263
|
+
return String(hit.id).toLowerCase();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// Non-JSON or unexpected shape — treat as "not found" and create one.
|
|
268
|
+
}
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The deployed Worker's public origin, e.g. https://name.sub.workers.dev.
|
|
273
|
+
export function parseWorkerDevUrl(output) {
|
|
274
|
+
const text = stripAnsi(output || "");
|
|
275
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev/i);
|
|
276
|
+
return match ? match[0].toLowerCase() : "";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Normalize the persisted feedback (reactions + analytics) settings. Returns
|
|
280
|
+
// null until the feedback Worker has been provisioned, so callers can treat the
|
|
281
|
+
// whole feature as off by checking for a truthy `feedback`.
|
|
282
|
+
function normalizeFeedback(feedback) {
|
|
283
|
+
if (!feedback || typeof feedback !== "object") {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const url = String(feedback.url || "").trim().replace(/\/+$/, "");
|
|
287
|
+
if (!/^https:\/\/[^\s/]+/i.test(url)) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
url,
|
|
292
|
+
statsToken: String(feedback.statsToken || ""),
|
|
293
|
+
workerName: String(feedback.workerName || ""),
|
|
294
|
+
kvId: String(feedback.kvId || "")
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
231
298
|
function normalizeConfig(config = {}) {
|
|
232
299
|
const projectName = normalizePagesProjectName(
|
|
233
300
|
config.pages?.projectName || DEFAULT_PAGES_PROJECT_NAME
|
|
@@ -242,7 +309,8 @@ function normalizeConfig(config = {}) {
|
|
|
242
309
|
accountName,
|
|
243
310
|
branch: DEFAULT_PAGES_BRANCH,
|
|
244
311
|
baseUrl: pagesBaseUrl(projectName)
|
|
245
|
-
}
|
|
312
|
+
},
|
|
313
|
+
feedback: normalizeFeedback(config.feedback)
|
|
246
314
|
};
|
|
247
315
|
}
|
|
248
316
|
|
|
@@ -889,7 +957,19 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
|
|
|
889
957
|
projectName: projectName === undefined ? config.pages.projectName : projectName,
|
|
890
958
|
accountId: nextAccountId,
|
|
891
959
|
accountName: nextAccountName
|
|
892
|
-
}
|
|
960
|
+
},
|
|
961
|
+
// Preserve feedback config — a pages update (e.g. persisting the account on
|
|
962
|
+
// publish) must not wipe an already-provisioned feedback Worker.
|
|
963
|
+
feedback: config.feedback
|
|
964
|
+
});
|
|
965
|
+
await save();
|
|
966
|
+
return get();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function updateFeedback(feedback) {
|
|
970
|
+
config = normalizeConfig({
|
|
971
|
+
pages: config.pages,
|
|
972
|
+
feedback: feedback === null ? null : { ...(config.feedback || {}), ...feedback }
|
|
893
973
|
});
|
|
894
974
|
await save();
|
|
895
975
|
return get();
|
|
@@ -899,6 +979,7 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
|
|
|
899
979
|
init,
|
|
900
980
|
get,
|
|
901
981
|
updatePages,
|
|
982
|
+
updateFeedback,
|
|
902
983
|
configPath
|
|
903
984
|
};
|
|
904
985
|
}
|
|
@@ -923,11 +1004,41 @@ export function createDeployQueue() {
|
|
|
923
1004
|
return { enqueue };
|
|
924
1005
|
}
|
|
925
1006
|
|
|
1007
|
+
// Insert the feedback widget into a published HTML document. The widget (served
|
|
1008
|
+
// by the user's feedback Worker) beacons a view and renders the reactions bar.
|
|
1009
|
+
// Injected just before </body> so it loads after page content. `url` is the
|
|
1010
|
+
// Worker origin and `slug` keys this page's stats. Returns the HTML unchanged
|
|
1011
|
+
// when feedback is not configured. Pure + exported for testing.
|
|
1012
|
+
export function injectFeedbackWidget(html, { url, slug } = {}) {
|
|
1013
|
+
const baseUrl = String(url || "").trim().replace(/\/+$/, "");
|
|
1014
|
+
const pageSlug = String(slug || "").trim();
|
|
1015
|
+
if (!baseUrl || !pageSlug) {
|
|
1016
|
+
return html;
|
|
1017
|
+
}
|
|
1018
|
+
const esc = (value) =>
|
|
1019
|
+
String(value)
|
|
1020
|
+
.replace(/&/g, "&")
|
|
1021
|
+
.replace(/"/g, """)
|
|
1022
|
+
.replace(/</g, "<")
|
|
1023
|
+
.replace(/>/g, ">");
|
|
1024
|
+
const tag =
|
|
1025
|
+
`<script src="${esc(`${baseUrl}/widget.js`)}" data-slug="${esc(pageSlug)}" defer></script>`;
|
|
1026
|
+
// Avoid double-injecting if the document already carries the widget.
|
|
1027
|
+
if (html.includes(`data-slug="${esc(pageSlug)}"`) && html.includes("/widget.js")) {
|
|
1028
|
+
return html;
|
|
1029
|
+
}
|
|
1030
|
+
if (/<\/body>/i.test(html)) {
|
|
1031
|
+
return html.replace(/<\/body>/i, `${tag}\n</body>`);
|
|
1032
|
+
}
|
|
1033
|
+
return `${html}\n${tag}\n`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
926
1036
|
export function createCloudflarePagesPublisher({
|
|
927
1037
|
dataDir = path.join(PROJECT_ROOT, ".pagecast"),
|
|
928
1038
|
spawnImpl = spawn,
|
|
929
1039
|
timeoutMs = 180000,
|
|
930
|
-
getRedirects = () => []
|
|
1040
|
+
getRedirects = () => [],
|
|
1041
|
+
getFeedback = () => null
|
|
931
1042
|
} = {}) {
|
|
932
1043
|
const siteRoot = path.join(dataDir, "pages-site");
|
|
933
1044
|
const deployRoot = path.join(dataDir, "pages-deploy");
|
|
@@ -966,18 +1077,28 @@ export function createCloudflarePagesPublisher({
|
|
|
966
1077
|
}
|
|
967
1078
|
|
|
968
1079
|
async function stagePublication(report, publication) {
|
|
969
|
-
const
|
|
1080
|
+
const slug = publication.slug || publication.token;
|
|
1081
|
+
const destinationRoot = publicationDir(slug);
|
|
970
1082
|
const sourceRoot = publishSourceFor(report);
|
|
971
1083
|
await copyPublicTree(sourceRoot, destinationRoot);
|
|
1084
|
+
|
|
1085
|
+
const indexPath = path.join(destinationRoot, "index.html");
|
|
1086
|
+
let html;
|
|
972
1087
|
if (isMarkdownFileName(report.entryFile)) {
|
|
973
1088
|
// Render the raw markdown entry to real HTML so the published Cloudflare
|
|
974
1089
|
// site serves a proper document; sibling assets were copied above.
|
|
975
1090
|
const markdown = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
|
|
976
|
-
|
|
977
|
-
await fs.writeFile(path.join(destinationRoot, "index.html"), html, "utf8");
|
|
1091
|
+
html = markdownToHtml(markdown, { title: report.name });
|
|
978
1092
|
} else {
|
|
979
|
-
await fs.
|
|
1093
|
+
html = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
|
|
980
1094
|
}
|
|
1095
|
+
|
|
1096
|
+
// Inject the reactions + analytics widget when feedback is provisioned.
|
|
1097
|
+
const feedback = getFeedback();
|
|
1098
|
+
if (feedback?.url) {
|
|
1099
|
+
html = injectFeedbackWidget(html, { url: feedback.url, slug });
|
|
1100
|
+
}
|
|
1101
|
+
await fs.writeFile(indexPath, html, "utf8");
|
|
981
1102
|
}
|
|
982
1103
|
|
|
983
1104
|
async function removePublication(slug) {
|
|
@@ -1258,8 +1379,8 @@ export function createCloudflareAuthManager({
|
|
|
1258
1379
|
// connect/refresh time; the cache is invalidated whenever login state changes.
|
|
1259
1380
|
let sessionCache = null;
|
|
1260
1381
|
|
|
1261
|
-
async function login() {
|
|
1262
|
-
const scopedArgs =
|
|
1382
|
+
async function login(scopes = CLOUDFLARE_OAUTH_SCOPES) {
|
|
1383
|
+
const scopedArgs = scopes.flatMap((scope) => ["--scopes", scope]);
|
|
1263
1384
|
await runWrangler(["login", ...scopedArgs], loginTimeoutMs);
|
|
1264
1385
|
sessionCache = null;
|
|
1265
1386
|
}
|
|
@@ -1342,6 +1463,98 @@ export function createCloudflareAuthManager({
|
|
|
1342
1463
|
return name;
|
|
1343
1464
|
}
|
|
1344
1465
|
|
|
1466
|
+
// Provision the feedback Worker: reuse-or-create a KV namespace, stage the
|
|
1467
|
+
// worker + a generated wrangler.toml, and deploy it to the account's
|
|
1468
|
+
// workers.dev. Returns { url, kvId, workerName, statsToken }. Side-effecting
|
|
1469
|
+
// (creates real Cloudflare resources) — only run on explicit user action.
|
|
1470
|
+
async function setupFeedback({
|
|
1471
|
+
accountId = "",
|
|
1472
|
+
workerName = "pagecast-feedback",
|
|
1473
|
+
workerSource = "",
|
|
1474
|
+
statsToken = "",
|
|
1475
|
+
deployDir,
|
|
1476
|
+
timeoutMs = 120000
|
|
1477
|
+
} = {}) {
|
|
1478
|
+
if (!workerSource) {
|
|
1479
|
+
throw appError("Feedback Worker source was not found in the package.", 500);
|
|
1480
|
+
}
|
|
1481
|
+
if (!deployDir) {
|
|
1482
|
+
throw appError("A deploy directory is required to set up feedback.", 500);
|
|
1483
|
+
}
|
|
1484
|
+
const env = accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {};
|
|
1485
|
+
const kvTitle = `${workerName}-store`;
|
|
1486
|
+
|
|
1487
|
+
const provision = async () => {
|
|
1488
|
+
// 1. Reuse or create the KV namespace.
|
|
1489
|
+
let kvId = "";
|
|
1490
|
+
try {
|
|
1491
|
+
const listOut = await runWrangler(["kv", "namespace", "list"], timeoutMs, env);
|
|
1492
|
+
kvId = findKvNamespaceId(listOut, kvTitle);
|
|
1493
|
+
} catch {
|
|
1494
|
+
// Listing isn't available/authorized — fall through to create.
|
|
1495
|
+
}
|
|
1496
|
+
if (!kvId) {
|
|
1497
|
+
const createOut = await runWrangler(
|
|
1498
|
+
["kv", "namespace", "create", kvTitle],
|
|
1499
|
+
timeoutMs,
|
|
1500
|
+
env
|
|
1501
|
+
);
|
|
1502
|
+
kvId = parseKvNamespaceId(createOut);
|
|
1503
|
+
}
|
|
1504
|
+
if (!kvId) {
|
|
1505
|
+
throw appError("Could not create the feedback KV namespace.", 502);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// 2. Stage worker.js + a generated wrangler.toml in a clean temp dir.
|
|
1509
|
+
await fs.rm(deployDir, { recursive: true, force: true });
|
|
1510
|
+
await fs.mkdir(deployDir, { recursive: true });
|
|
1511
|
+
await fs.writeFile(path.join(deployDir, "worker.js"), workerSource, "utf8");
|
|
1512
|
+
const toml = [
|
|
1513
|
+
`name = "${workerName}"`,
|
|
1514
|
+
`main = "worker.js"`,
|
|
1515
|
+
`compatibility_date = "2024-09-01"`,
|
|
1516
|
+
`workers_dev = true`,
|
|
1517
|
+
``,
|
|
1518
|
+
`[[kv_namespaces]]`,
|
|
1519
|
+
`binding = "PAGECAST_FEEDBACK"`,
|
|
1520
|
+
`id = "${kvId}"`,
|
|
1521
|
+
``,
|
|
1522
|
+
`[vars]`,
|
|
1523
|
+
`PAGECAST_STATS_TOKEN = "${statsToken}"`,
|
|
1524
|
+
``
|
|
1525
|
+
].join("\n");
|
|
1526
|
+
await fs.writeFile(path.join(deployDir, "wrangler.toml"), toml, "utf8");
|
|
1527
|
+
|
|
1528
|
+
// 3. Deploy. Wrangler resolves `main` relative to the config file's dir.
|
|
1529
|
+
const deployOut = await runWrangler(
|
|
1530
|
+
["deploy", "--config", path.join(deployDir, "wrangler.toml")],
|
|
1531
|
+
timeoutMs,
|
|
1532
|
+
env
|
|
1533
|
+
);
|
|
1534
|
+
const url = parseWorkerDevUrl(deployOut);
|
|
1535
|
+
if (!url) {
|
|
1536
|
+
throw appError(
|
|
1537
|
+
"Feedback Worker deployed but no workers.dev URL was returned. Enable a workers.dev subdomain in your Cloudflare dashboard, then retry.",
|
|
1538
|
+
502
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
return { url, kvId, workerName, statsToken };
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
return await provision();
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
// The base publishing OAuth lacks Workers/KV permission, surfacing as a
|
|
1548
|
+
// Cloudflare "Authentication error [code: 10000]". Elevate the grant to the
|
|
1549
|
+
// feedback scopes once, then retry. (No-op if a token is already broad.)
|
|
1550
|
+
if (/code:\s*10000|authentication error/i.test(stripAnsi(error.message || ""))) {
|
|
1551
|
+
await login(FEEDBACK_OAUTH_SCOPES);
|
|
1552
|
+
return await provision();
|
|
1553
|
+
}
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1345
1558
|
function cachedSession() {
|
|
1346
1559
|
return sessionCache ? sessionCache.value : { loggedIn: false, accounts: [] };
|
|
1347
1560
|
}
|
|
@@ -1369,6 +1582,7 @@ export function createCloudflareAuthManager({
|
|
|
1369
1582
|
loginAndListProjects,
|
|
1370
1583
|
whoami,
|
|
1371
1584
|
ensureProject,
|
|
1585
|
+
setupFeedback,
|
|
1372
1586
|
cachedSession,
|
|
1373
1587
|
refreshSession,
|
|
1374
1588
|
invalidateSession
|
|
@@ -2034,6 +2248,28 @@ export function createReportStore({
|
|
|
2034
2248
|
return { statusCode: 404, message: "Report asset was not found." };
|
|
2035
2249
|
}
|
|
2036
2250
|
|
|
2251
|
+
// Symlink-escape guard for sibling assets. A symlink inside the report folder
|
|
2252
|
+
// (e.g. leak.txt -> /etc/passwd) passes the lexical isPathInside check above,
|
|
2253
|
+
// but fs.stat follows it, so without this a crafted symlink could serve files
|
|
2254
|
+
// outside the report root. Resolve the real path and re-verify containment.
|
|
2255
|
+
// (This mirrors the symlink rejection already enforced when staging snapshots
|
|
2256
|
+
// for Cloudflare.) The entry file itself is exempt: a `path` report can point
|
|
2257
|
+
// at a file the user deliberately chose, including a symlink.
|
|
2258
|
+
if (relativeAssetPath !== "") {
|
|
2259
|
+
try {
|
|
2260
|
+
const realRoot = await fs.realpath(rootDir);
|
|
2261
|
+
const realTarget = await fs.realpath(targetPath);
|
|
2262
|
+
if (!isPathInside(realRoot, realTarget)) {
|
|
2263
|
+
return { statusCode: 403, message: "Asset path is not allowed." };
|
|
2264
|
+
}
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error.code === "ENOENT") {
|
|
2267
|
+
return { statusCode: 404, message: "Report asset was not found." };
|
|
2268
|
+
}
|
|
2269
|
+
throw error;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2037
2273
|
// When the requested asset IS a markdown entry, render it to HTML in memory
|
|
2038
2274
|
// so the local preview serves a real document. Sibling assets (images, css)
|
|
2039
2275
|
// continue to resolve as files. Published snapshots are rendered on disk by
|
|
@@ -2960,6 +3196,46 @@ export function createPublicHandler({ store }) {
|
|
|
2960
3196
|
};
|
|
2961
3197
|
}
|
|
2962
3198
|
|
|
3199
|
+
// DNS-rebinding defense for the admin server. The admin API is unauthenticated
|
|
3200
|
+
// and can run shell (folder build commands), so it must only answer requests
|
|
3201
|
+
// addressed to a loopback host. A malicious web page that rebinds its own domain
|
|
3202
|
+
// to 127.0.0.1 still sends *its* Host header (e.g. "evil.example"), which fails
|
|
3203
|
+
// this check, while the real admin UI on 127.0.0.1/localhost passes.
|
|
3204
|
+
export function isLoopbackHostHeader(hostHeader, bindHost) {
|
|
3205
|
+
if (!hostHeader) {
|
|
3206
|
+
// No Host header (HTTP/1.0, some internal callers) — the request cannot have
|
|
3207
|
+
// come from a rebound browser origin, so allow it.
|
|
3208
|
+
return true;
|
|
3209
|
+
}
|
|
3210
|
+
let hostname = String(hostHeader);
|
|
3211
|
+
const ipv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(hostname);
|
|
3212
|
+
if (ipv6) {
|
|
3213
|
+
// Bracketed IPv6: "[::1]:4173" -> "::1".
|
|
3214
|
+
hostname = ipv6[1];
|
|
3215
|
+
} else if ((hostname.match(/:/g) || []).length <= 1) {
|
|
3216
|
+
// "host:port" or bare "host" — strip a single trailing ":port" only. A value
|
|
3217
|
+
// with more than one colon is a bare IPv6 literal (e.g. "::1") and is left
|
|
3218
|
+
// intact rather than truncated at its first colon.
|
|
3219
|
+
const colon = hostname.lastIndexOf(":");
|
|
3220
|
+
if (colon > -1) {
|
|
3221
|
+
hostname = hostname.slice(0, colon);
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
hostname = hostname.toLowerCase();
|
|
3225
|
+
if (hostname === "localhost" || hostname === "::1") {
|
|
3226
|
+
return true;
|
|
3227
|
+
}
|
|
3228
|
+
// The entire 127.0.0.0/8 block is loopback.
|
|
3229
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
|
|
3230
|
+
return true;
|
|
3231
|
+
}
|
|
3232
|
+
// An explicitly configured non-default bind host is trusted by definition.
|
|
3233
|
+
if (bindHost && hostname === String(bindHost).toLowerCase()) {
|
|
3234
|
+
return true;
|
|
3235
|
+
}
|
|
3236
|
+
return false;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
2963
3239
|
export function createAdminHandler({
|
|
2964
3240
|
store,
|
|
2965
3241
|
configStore,
|
|
@@ -2970,10 +3246,19 @@ export function createAdminHandler({
|
|
|
2970
3246
|
getLocalPublicBaseUrl,
|
|
2971
3247
|
tunnelManager,
|
|
2972
3248
|
deployQueue,
|
|
2973
|
-
watchManager
|
|
3249
|
+
watchManager,
|
|
3250
|
+
bindHost = DEFAULT_HOST
|
|
2974
3251
|
}) {
|
|
2975
3252
|
return async function adminHandler(req, res) {
|
|
2976
3253
|
try {
|
|
3254
|
+
if (!isLoopbackHostHeader(req.headers.host, bindHost)) {
|
|
3255
|
+
sendText(
|
|
3256
|
+
res,
|
|
3257
|
+
403,
|
|
3258
|
+
"Forbidden: the Pagecast admin server only accepts loopback (localhost) requests."
|
|
3259
|
+
);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
2977
3262
|
const url = new URL(req.url, `http://${req.headers.host || DEFAULT_HOST}`);
|
|
2978
3263
|
|
|
2979
3264
|
if (url.pathname.startsWith("/api/")) {
|
|
@@ -3094,6 +3379,60 @@ async function handleApi(
|
|
|
3094
3379
|
return;
|
|
3095
3380
|
}
|
|
3096
3381
|
|
|
3382
|
+
// Provision (or re-provision) the feedback Worker + KV on the user's account.
|
|
3383
|
+
// Creates real Cloudflare resources, so it only runs on this explicit action.
|
|
3384
|
+
if (url.pathname === "/api/feedback/setup" && req.method === "POST") {
|
|
3385
|
+
const body = await readJsonBody(req);
|
|
3386
|
+
const pages = configStore.get().pages;
|
|
3387
|
+
const accountId = normalizeAccountId(body.accountId || pages.accountId || "");
|
|
3388
|
+
const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
|
|
3389
|
+
let workerSource;
|
|
3390
|
+
try {
|
|
3391
|
+
workerSource = await fs.readFile(workerPath, "utf8");
|
|
3392
|
+
} catch {
|
|
3393
|
+
sendError(res, appError("Feedback Worker source not found in the package.", 500));
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
const existing = configStore.get().feedback;
|
|
3397
|
+
const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
|
|
3398
|
+
const dataDir = path.dirname(configStore.configPath);
|
|
3399
|
+
try {
|
|
3400
|
+
const result = await cloudflareAuth.setupFeedback({
|
|
3401
|
+
accountId,
|
|
3402
|
+
workerSource,
|
|
3403
|
+
statsToken,
|
|
3404
|
+
deployDir: path.join(dataDir, "feedback-deploy")
|
|
3405
|
+
});
|
|
3406
|
+
const config = await configStore.updateFeedback(result);
|
|
3407
|
+
sendJson(res, 200, { config, feedback: config.feedback });
|
|
3408
|
+
} catch (error) {
|
|
3409
|
+
sendError(res, error);
|
|
3410
|
+
}
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
// Read aggregate stats for a published page back from the feedback Worker.
|
|
3415
|
+
// Proxied through the local server so the stats token never reaches the UI.
|
|
3416
|
+
if (url.pathname === "/api/feedback/stats" && req.method === "GET") {
|
|
3417
|
+
const feedback = configStore.get().feedback;
|
|
3418
|
+
if (!feedback?.url) {
|
|
3419
|
+
sendJson(res, 200, { ok: true, configured: false, stats: null });
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
const slug = url.searchParams.get("slug") || "";
|
|
3423
|
+
const statsUrl =
|
|
3424
|
+
`${feedback.url}/api/v1/stats?slug=${encodeURIComponent(slug)}` +
|
|
3425
|
+
`&token=${encodeURIComponent(feedback.statsToken)}`;
|
|
3426
|
+
try {
|
|
3427
|
+
const response = await fetch(statsUrl);
|
|
3428
|
+
const data = await response.json().catch(() => ({}));
|
|
3429
|
+
sendJson(res, 200, { ok: response.ok, configured: true, ...data });
|
|
3430
|
+
} catch {
|
|
3431
|
+
sendError(res, appError("Could not reach the feedback service.", 502));
|
|
3432
|
+
}
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3097
3436
|
if (url.pathname === "/api/cloudflare/login" && req.method === "POST") {
|
|
3098
3437
|
await readJsonBody(req);
|
|
3099
3438
|
await cloudflareAuth.login();
|
|
@@ -3522,14 +3861,20 @@ export async function startServers({
|
|
|
3522
3861
|
dataDir,
|
|
3523
3862
|
spawnImpl: pagesDeploySpawnImpl,
|
|
3524
3863
|
timeoutMs: pagesDeployTimeoutMs,
|
|
3525
|
-
getRedirects: () => store.listRedirects()
|
|
3864
|
+
getRedirects: () => store.listRedirects(),
|
|
3865
|
+
getFeedback: () => configStore.get().feedback
|
|
3526
3866
|
});
|
|
3527
3867
|
const deployQueue = createDeployQueue();
|
|
3528
3868
|
const watchManager = createWatchManager({
|
|
3529
3869
|
store,
|
|
3530
3870
|
pagesPublisher,
|
|
3531
3871
|
configStore,
|
|
3532
|
-
deployQueue
|
|
3872
|
+
deployQueue,
|
|
3873
|
+
// Auto-sync runs in the background; surface failures (e.g. expired Cloudflare
|
|
3874
|
+
// auth) instead of swallowing them, so a silently-broken watch is visible.
|
|
3875
|
+
onError: (error) => {
|
|
3876
|
+
console.warn(`Pagecast auto-sync failed: ${error?.message || error}`);
|
|
3877
|
+
}
|
|
3533
3878
|
});
|
|
3534
3879
|
for (const report of store.listAutoSyncReports()) {
|
|
3535
3880
|
watchManager.register(report.id);
|
|
@@ -3557,7 +3902,8 @@ export async function startServers({
|
|
|
3557
3902
|
getLocalPublicBaseUrl: () => localPublicBaseUrl,
|
|
3558
3903
|
tunnelManager,
|
|
3559
3904
|
deployQueue,
|
|
3560
|
-
watchManager
|
|
3905
|
+
watchManager,
|
|
3906
|
+
bindHost: host
|
|
3561
3907
|
})
|
|
3562
3908
|
);
|
|
3563
3909
|
|
|
@@ -3608,7 +3954,10 @@ async function createHeadlessCloudflareContext({
|
|
|
3608
3954
|
const pagesPublisher = createCloudflarePagesPublisher({
|
|
3609
3955
|
dataDir,
|
|
3610
3956
|
spawnImpl: pagesDeploySpawnImpl,
|
|
3611
|
-
timeoutMs: pagesDeployTimeoutMs
|
|
3957
|
+
timeoutMs: pagesDeployTimeoutMs,
|
|
3958
|
+
// Headless/CLI publishes (incl. the agent skill's `npx pagecast publish`)
|
|
3959
|
+
// must inject the feedback widget too, not just the running app.
|
|
3960
|
+
getFeedback: () => configStore.get().feedback
|
|
3612
3961
|
});
|
|
3613
3962
|
return { configStore, cloudflareAuth, pagesPublisher };
|
|
3614
3963
|
}
|
|
@@ -3701,6 +4050,45 @@ export async function setupCloudflarePages({
|
|
|
3701
4050
|
};
|
|
3702
4051
|
}
|
|
3703
4052
|
|
|
4053
|
+
// Provision the feedback Worker + KV on the user's account and persist the
|
|
4054
|
+
// resulting config. Reuses an existing stats token/namespace on re-run.
|
|
4055
|
+
export async function setupCloudflareFeedback({
|
|
4056
|
+
accountId,
|
|
4057
|
+
dataDir = path.join(PROJECT_ROOT, ".pagecast"),
|
|
4058
|
+
cloudflareAuthSpawnImpl = spawn,
|
|
4059
|
+
cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
|
|
4060
|
+
feedbackTimeoutMs = 120000
|
|
4061
|
+
} = {}) {
|
|
4062
|
+
const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
|
|
4063
|
+
dataDir,
|
|
4064
|
+
cloudflareAuthSpawnImpl,
|
|
4065
|
+
cloudflareListTimeoutMs
|
|
4066
|
+
});
|
|
4067
|
+
const pages = configStore.get().pages;
|
|
4068
|
+
const resolvedAccountId = normalizeAccountId(accountId || pages.accountId || "");
|
|
4069
|
+
|
|
4070
|
+
const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
|
|
4071
|
+
let workerSource;
|
|
4072
|
+
try {
|
|
4073
|
+
workerSource = await fs.readFile(workerPath, "utf8");
|
|
4074
|
+
} catch {
|
|
4075
|
+
throw appError("Feedback Worker source not found in the package.", 500);
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
const existing = configStore.get().feedback;
|
|
4079
|
+
const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
|
|
4080
|
+
|
|
4081
|
+
const result = await cloudflareAuth.setupFeedback({
|
|
4082
|
+
accountId: resolvedAccountId,
|
|
4083
|
+
workerSource,
|
|
4084
|
+
statsToken,
|
|
4085
|
+
deployDir: path.join(dataDir, "feedback-deploy")
|
|
4086
|
+
});
|
|
4087
|
+
|
|
4088
|
+
const config = await configStore.updateFeedback(result);
|
|
4089
|
+
return { config, feedback: config.feedback };
|
|
4090
|
+
}
|
|
4091
|
+
|
|
3704
4092
|
export async function getCloudflarePagesStatus({
|
|
3705
4093
|
dataDir = path.join(PROJECT_ROOT, ".pagecast"),
|
|
3706
4094
|
cloudflareAuthSpawnImpl = spawn,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{r as O,g as S}from"./motion-vendor-CDgKLzlB.js";var d={exports:{}},n={};var l;function R(){if(l)return n;l=1;var u=O();function g(e){var r="https://react.dev/errors/"+e;if(1<arguments.length){r+="?args[]="+encodeURIComponent(arguments[1]);for(var t=2;t<arguments.length;t++)r+="&args[]="+encodeURIComponent(arguments[t])}return"Minified React error #"+e+"; visit "+r+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function a(){}var i={d:{f:a,r:function(){throw Error(g(522))},D:a,C:a,L:a,m:a,X:a,S:a,M:a},p:0,findDOMNode:null},m=Symbol.for("react.portal");function v(e,r,t){var c=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:m,key:c==null?null:""+c,children:e,containerInfo:r,implementation:t}}var f=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function s(e,r){if(e==="font")return"";if(typeof r=="string")return r==="use-credentials"?r:""}return n.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=i,n.createPortal=function(e,r){var t=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!r||r.nodeType!==1&&r.nodeType!==9&&r.nodeType!==11)throw Error(g(299));return v(e,r,null,t)},n.flushSync=function(e){var r=f.T,t=i.p;try{if(f.T=null,i.p=2,e)return e()}finally{f.T=r,i.p=t,i.d.f()}},n.preconnect=function(e,r){typeof e=="string"&&(r?(r=r.crossOrigin,r=typeof r=="string"?r==="use-credentials"?r:"":void 0):r=null,i.d.C(e,r))},n.prefetchDNS=function(e){typeof e=="string"&&i.d.D(e)},n.preinit=function(e,r){if(typeof e=="string"&&r&&typeof r.as=="string"){var t=r.as,c=s(t,r.crossOrigin),y=typeof r.integrity=="string"?r.integrity:void 0,o=typeof r.fetchPriority=="string"?r.fetchPriority:void 0;t==="style"?i.d.S(e,typeof r.precedence=="string"?r.precedence:void 0,{crossOrigin:c,integrity:y,fetchPriority:o}):t==="script"&&i.d.X(e,{crossOrigin:c,integrity:y,fetchPriority:o,nonce:typeof r.nonce=="string"?r.nonce:void 0})}},n.preinitModule=function(e,r){if(typeof e=="string")if(typeof r=="object"&&r!==null){if(r.as==null||r.as==="script"){var t=s(r.as,r.crossOrigin);i.d.M(e,{crossOrigin:t,integrity:typeof r.integrity=="string"?r.integrity:void 0,nonce:typeof r.nonce=="string"?r.nonce:void 0})}}else r==null&&i.d.M(e)},n.preload=function(e,r){if(typeof e=="string"&&typeof r=="object"&&r!==null&&typeof r.as=="string"){var t=r.as,c=s(t,r.crossOrigin);i.d.L(e,t,{crossOrigin:c,integrity:typeof r.integrity=="string"?r.integrity:void 0,nonce:typeof r.nonce=="string"?r.nonce:void 0,type:typeof r.type=="string"?r.type:void 0,fetchPriority:typeof r.fetchPriority=="string"?r.fetchPriority:void 0,referrerPolicy:typeof r.referrerPolicy=="string"?r.referrerPolicy:void 0,imageSrcSet:typeof r.imageSrcSet=="string"?r.imageSrcSet:void 0,imageSizes:typeof r.imageSizes=="string"?r.imageSizes:void 0,media:typeof r.media=="string"?r.media:void 0})}},n.preloadModule=function(e,r){if(typeof e=="string")if(r){var t=s(r.as,r.crossOrigin);i.d.m(e,{as:typeof r.as=="string"&&r.as!=="script"?r.as:void 0,crossOrigin:t,integrity:typeof r.integrity=="string"?r.integrity:void 0})}else i.d.m(e)},n.requestFormReset=function(e){i.d.r(e)},n.unstable_batchedUpdates=function(e,r){return e(r)},n.useFormState=function(e,r,t){return f.H.useFormState(e,r,t)},n.useFormStatus=function(){return f.H.useHostTransitionStatus()},n.version="19.2.6",n}var _;function E(){if(_)return d.exports;_=1;function u(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(u)}catch(g){console.error(g)}}return u(),d.exports=R(),d.exports}var T=E();const h=S(T);export{h as R,T as a,E as r};
|