sproutkit-code 1.6.1 → 1.7.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/dist/index.js +306 -29
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -225,6 +225,10 @@ var require_db = __commonJS({
|
|
|
225
225
|
// redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
|
|
226
226
|
// it would make the client's self-chosen password undecryptable each time.
|
|
227
227
|
"auth_enc_key TEXT",
|
|
228
|
+
// Read-only per-site key so `sproutkit report` can read visitor stats
|
|
229
|
+
// WITHOUT the client's password. Means handing the client full ownership
|
|
230
|
+
// doesn't cost the agency its reporting.
|
|
231
|
+
"report_key TEXT",
|
|
228
232
|
// Reply loop: when we last contacted them, and the latest outcome. Kept on
|
|
229
233
|
// the lead (as well as contact_log) so `followups` is a single cheap query.
|
|
230
234
|
"last_contacted_at TEXT",
|
|
@@ -31319,6 +31323,7 @@ var require_analytics_runtime = __commonJS({
|
|
|
31319
31323
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
31320
31324
|
exports2.TRACK_SNIPPET = exports2.API_STATS_JS = exports2.API_TRACK_JS = void 0;
|
|
31321
31325
|
exports2.API_TRACK_JS = `import { list, put } from "@vercel/blob";
|
|
31326
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
31322
31327
|
|
|
31323
31328
|
export const config = { runtime: "nodejs" };
|
|
31324
31329
|
|
|
@@ -31333,6 +31338,54 @@ const EVENTS = ["view", "call", "book", "directions", "email", "menu"];
|
|
|
31333
31338
|
// the file has to stay small enough to read on every request.
|
|
31334
31339
|
const MAX_DAYS = 400;
|
|
31335
31340
|
|
|
31341
|
+
|
|
31342
|
+
// stats.json lives in a PUBLIC Blob at a non-random path, and the store
|
|
31343
|
+
// hostname leaks through every photo URL on the site \u2014 so the file's URL must
|
|
31344
|
+
// be assumed guessable. Visitor counts are the client's commercial data and the
|
|
31345
|
+
// agency's renewal evidence, so the contents are encrypted with AES-256-GCM
|
|
31346
|
+
// under AUTH_ENC_KEY (a server-side env var). A direct public read yields
|
|
31347
|
+
// opaque bytes instead of the numbers.
|
|
31348
|
+
function statsKey() {
|
|
31349
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31350
|
+
if (!material) return null;
|
|
31351
|
+
return createHash("sha256").update("sproutkit-stats:" + material).digest();
|
|
31352
|
+
}
|
|
31353
|
+
|
|
31354
|
+
function sealStats(obj) {
|
|
31355
|
+
const key = statsKey();
|
|
31356
|
+
if (!key) return JSON.stringify({ v: 0, plain: obj });
|
|
31357
|
+
const iv = randomBytes(12);
|
|
31358
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
31359
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
|
|
31360
|
+
return JSON.stringify({
|
|
31361
|
+
v: 1,
|
|
31362
|
+
iv: iv.toString("base64"),
|
|
31363
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
31364
|
+
ct: ct.toString("base64"),
|
|
31365
|
+
});
|
|
31366
|
+
}
|
|
31367
|
+
|
|
31368
|
+
function openStats(raw) {
|
|
31369
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31370
|
+
// Unencrypted shapes: v0 (no key available) and pre-encryption files that
|
|
31371
|
+
// stored { days } directly. Both must keep working.
|
|
31372
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31373
|
+
if (raw.days && raw.v === undefined) return raw;
|
|
31374
|
+
if (raw.v !== 1) return null;
|
|
31375
|
+
const key = statsKey();
|
|
31376
|
+
if (!key) return null;
|
|
31377
|
+
try {
|
|
31378
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31379
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31380
|
+
return JSON.parse(
|
|
31381
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31382
|
+
);
|
|
31383
|
+
} catch {
|
|
31384
|
+
// Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
|
|
31385
|
+
return null;
|
|
31386
|
+
}
|
|
31387
|
+
}
|
|
31388
|
+
|
|
31336
31389
|
function today() {
|
|
31337
31390
|
return new Date().toISOString().slice(0, 10);
|
|
31338
31391
|
}
|
|
@@ -31344,7 +31397,7 @@ async function readStats() {
|
|
|
31344
31397
|
if (!target) return { days: {} };
|
|
31345
31398
|
const res = await fetch(target.url, { cache: "no-store" });
|
|
31346
31399
|
if (!res.ok) return { days: {} };
|
|
31347
|
-
const j = await res.json();
|
|
31400
|
+
const j = openStats(await res.json());
|
|
31348
31401
|
return j && typeof j === "object" && j.days ? j : { days: {} };
|
|
31349
31402
|
} catch {
|
|
31350
31403
|
return { days: {} };
|
|
@@ -31391,7 +31444,7 @@ export default async function handler(req, res) {
|
|
|
31391
31444
|
}
|
|
31392
31445
|
|
|
31393
31446
|
try {
|
|
31394
|
-
await put(STATS_KEY,
|
|
31447
|
+
await put(STATS_KEY, sealStats(stats), {
|
|
31395
31448
|
access: "public",
|
|
31396
31449
|
addRandomSuffix: false,
|
|
31397
31450
|
allowOverwrite: true,
|
|
@@ -31404,12 +31457,110 @@ export default async function handler(req, res) {
|
|
|
31404
31457
|
}
|
|
31405
31458
|
`;
|
|
31406
31459
|
exports2.API_STATS_JS = `import { list } from "@vercel/blob";
|
|
31407
|
-
import {
|
|
31460
|
+
import {
|
|
31461
|
+
createHmac,
|
|
31462
|
+
timingSafeEqual,
|
|
31463
|
+
createHash,
|
|
31464
|
+
createCipheriv,
|
|
31465
|
+
createDecipheriv,
|
|
31466
|
+
randomBytes,
|
|
31467
|
+
} from "node:crypto";
|
|
31408
31468
|
|
|
31409
31469
|
export const config = { runtime: "nodejs" };
|
|
31410
31470
|
|
|
31411
31471
|
const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
|
|
31412
31472
|
const STATS_KEY = PFX + "stats.json";
|
|
31473
|
+
const AUTH_KEY = PFX + "admin-auth.json";
|
|
31474
|
+
|
|
31475
|
+
|
|
31476
|
+
// stats.json lives in a PUBLIC Blob at a non-random path, and the store
|
|
31477
|
+
// hostname leaks through every photo URL on the site \u2014 so the file's URL must
|
|
31478
|
+
// be assumed guessable. Visitor counts are the client's commercial data and the
|
|
31479
|
+
// agency's renewal evidence, so the contents are encrypted with AES-256-GCM
|
|
31480
|
+
// under AUTH_ENC_KEY (a server-side env var). A direct public read yields
|
|
31481
|
+
// opaque bytes instead of the numbers.
|
|
31482
|
+
function statsKey() {
|
|
31483
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31484
|
+
if (!material) return null;
|
|
31485
|
+
return createHash("sha256").update("sproutkit-stats:" + material).digest();
|
|
31486
|
+
}
|
|
31487
|
+
|
|
31488
|
+
function sealStats(obj) {
|
|
31489
|
+
const key = statsKey();
|
|
31490
|
+
if (!key) return JSON.stringify({ v: 0, plain: obj });
|
|
31491
|
+
const iv = randomBytes(12);
|
|
31492
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
31493
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
|
|
31494
|
+
return JSON.stringify({
|
|
31495
|
+
v: 1,
|
|
31496
|
+
iv: iv.toString("base64"),
|
|
31497
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
31498
|
+
ct: ct.toString("base64"),
|
|
31499
|
+
});
|
|
31500
|
+
}
|
|
31501
|
+
|
|
31502
|
+
function openStats(raw) {
|
|
31503
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31504
|
+
// Unencrypted shapes: v0 (no key available) and pre-encryption files that
|
|
31505
|
+
// stored { days } directly. Both must keep working.
|
|
31506
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31507
|
+
if (raw.days && raw.v === undefined) return raw;
|
|
31508
|
+
if (raw.v !== 1) return null;
|
|
31509
|
+
const key = statsKey();
|
|
31510
|
+
if (!key) return null;
|
|
31511
|
+
try {
|
|
31512
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31513
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31514
|
+
return JSON.parse(
|
|
31515
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31516
|
+
);
|
|
31517
|
+
} catch {
|
|
31518
|
+
// Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
|
|
31519
|
+
return null;
|
|
31520
|
+
}
|
|
31521
|
+
}
|
|
31522
|
+
|
|
31523
|
+
|
|
31524
|
+
// Tokens carry the credential version they were minted under. A password change
|
|
31525
|
+
// bumps it, so cookies issued earlier stop working \u2014 without this, "I changed my
|
|
31526
|
+
// password" wouldn't revoke anyone for up to 8 hours, and the client couldn't
|
|
31527
|
+
// actually take their site back from the agency.
|
|
31528
|
+
//
|
|
31529
|
+
// Fails OPEN when the blob is unreadable: a Blob hiccup must not lock an owner
|
|
31530
|
+
// out of their own site. The password itself is still required to get a token.
|
|
31531
|
+
async function tokenVersionOk(data) {
|
|
31532
|
+
try {
|
|
31533
|
+
const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
|
|
31534
|
+
const target = blobs.find((b) => b.pathname === AUTH_KEY);
|
|
31535
|
+
if (!target) return true;
|
|
31536
|
+
const res = await fetch(target.url, { cache: "no-store" });
|
|
31537
|
+
if (!res.ok) return true;
|
|
31538
|
+
const rec = openStatsRecord(await res.json());
|
|
31539
|
+
const current = rec && Number(rec.tokenVersion) ? Number(rec.tokenVersion) : 0;
|
|
31540
|
+
return (Number(data && data.ver) || 0) >= current;
|
|
31541
|
+
} catch {
|
|
31542
|
+
return true;
|
|
31543
|
+
}
|
|
31544
|
+
}
|
|
31545
|
+
|
|
31546
|
+
// admin-auth.json is sealed with a different key label than stats.json.
|
|
31547
|
+
function openStatsRecord(raw) {
|
|
31548
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31549
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31550
|
+
if (raw.v !== 1) return null;
|
|
31551
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31552
|
+
if (!material) return null;
|
|
31553
|
+
try {
|
|
31554
|
+
const key = createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
|
|
31555
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31556
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31557
|
+
return JSON.parse(
|
|
31558
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31559
|
+
);
|
|
31560
|
+
} catch {
|
|
31561
|
+
return null;
|
|
31562
|
+
}
|
|
31563
|
+
}
|
|
31413
31564
|
|
|
31414
31565
|
function verifySession(req) {
|
|
31415
31566
|
const cookie = req.headers.cookie || "";
|
|
@@ -31427,7 +31578,8 @@ function verifySession(req) {
|
|
|
31427
31578
|
const data = JSON.parse(
|
|
31428
31579
|
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
31429
31580
|
);
|
|
31430
|
-
|
|
31581
|
+
if (!(typeof data.exp === "number" && data.exp > Date.now() / 1000)) return false;
|
|
31582
|
+
return data;
|
|
31431
31583
|
} catch {
|
|
31432
31584
|
return false;
|
|
31433
31585
|
}
|
|
@@ -31450,8 +31602,32 @@ function shiftDays(n) {
|
|
|
31450
31602
|
return d.toISOString().slice(0, 10);
|
|
31451
31603
|
}
|
|
31452
31604
|
|
|
31605
|
+
// The agency needs these numbers for renewal conversations, but must NOT need
|
|
31606
|
+
// the client's password to get them \u2014 otherwise the agency permanently holds the
|
|
31607
|
+
// client's login, and the client changing it silently breaks reporting.
|
|
31608
|
+
//
|
|
31609
|
+
// REPORT_KEY is a separate, per-site, read-only credential. It is accepted ONLY
|
|
31610
|
+
// here: it cannot edit content, read bookings, or change the password. So handing
|
|
31611
|
+
// the client full ownership and keeping agency reporting are no longer in
|
|
31612
|
+
// conflict.
|
|
31613
|
+
function verifyReportKey(req) {
|
|
31614
|
+
const expected = process.env.REPORT_KEY;
|
|
31615
|
+
if (!expected) return false;
|
|
31616
|
+
const header = req.headers["authorization"] || req.headers["x-report-key"] || "";
|
|
31617
|
+
const provided = String(header).replace(/^Bearer\\s+/i, "");
|
|
31618
|
+
if (!provided || provided.length !== expected.length) return false;
|
|
31619
|
+
try {
|
|
31620
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
31621
|
+
} catch {
|
|
31622
|
+
return false;
|
|
31623
|
+
}
|
|
31624
|
+
}
|
|
31625
|
+
|
|
31453
31626
|
export default async function handler(req, res) {
|
|
31454
|
-
|
|
31627
|
+
const claims = verifySession(req);
|
|
31628
|
+
const viaSession = claims ? await tokenVersionOk(claims) : false;
|
|
31629
|
+
const viaReportKey = viaSession ? false : verifyReportKey(req);
|
|
31630
|
+
if (!viaSession && !viaReportKey) {
|
|
31455
31631
|
res.status(401).json({ error: "unauthorized" });
|
|
31456
31632
|
return;
|
|
31457
31633
|
}
|
|
@@ -31462,7 +31638,7 @@ export default async function handler(req, res) {
|
|
|
31462
31638
|
if (target) {
|
|
31463
31639
|
const r = await fetch(target.url, { cache: "no-store" });
|
|
31464
31640
|
if (r.ok) {
|
|
31465
|
-
const j = await r.json();
|
|
31641
|
+
const j = openStats(await r.json());
|
|
31466
31642
|
if (j && j.days) stats = j;
|
|
31467
31643
|
}
|
|
31468
31644
|
}
|
|
@@ -31473,6 +31649,7 @@ export default async function handler(req, res) {
|
|
|
31473
31649
|
const today = shiftDays(0);
|
|
31474
31650
|
res.setHeader("Cache-Control", "no-store");
|
|
31475
31651
|
res.status(200).json({
|
|
31652
|
+
via: viaSession ? "owner" : "report_key",
|
|
31476
31653
|
last30: windowTotals(stats.days, shiftDays(-29), today),
|
|
31477
31654
|
// The comparison window is what makes the number mean something.
|
|
31478
31655
|
prev30: windowTotals(stats.days, shiftDays(-59), shiftDays(-30)),
|
|
@@ -33339,7 +33516,7 @@ var require_cms_runtime = __commonJS({
|
|
|
33339
33516
|
};
|
|
33340
33517
|
})();
|
|
33341
33518
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
33342
|
-
exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
|
|
33519
|
+
exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_LOGOUT_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
|
|
33343
33520
|
exports2.generateFriendlyPassword = generateFriendlyPassword;
|
|
33344
33521
|
exports2.hashPassword = hashPassword;
|
|
33345
33522
|
exports2.generateSessionSecret = generateSessionSecret;
|
|
@@ -33715,11 +33892,23 @@ export default async function handler(req, res) {
|
|
|
33715
33892
|
delete throttle[who];
|
|
33716
33893
|
await writeJson(THROTTLE_KEY, throttle);
|
|
33717
33894
|
}
|
|
33718
|
-
|
|
33719
|
-
|
|
33895
|
+
// 2h, not 8h. /api/stats checks the credential version and revokes instantly,
|
|
33896
|
+
// but the content/upload/booking endpoints don't (each is a standalone
|
|
33897
|
+
// serverless file and adding the check to all of them means duplicating the
|
|
33898
|
+
// auth-blob crypto five more times). So a cookie issued before a password
|
|
33899
|
+
// change can still WRITE for up to this long. Two hours keeps that window
|
|
33900
|
+
// small; the honest fix is a shared auth module, which needs a bundler for the
|
|
33901
|
+
// generated site.
|
|
33902
|
+
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 2;
|
|
33903
|
+
// Stamp the credential version into the token. Changing the password bumps
|
|
33904
|
+
// this, which instantly invalidates every cookie issued before the change \u2014
|
|
33905
|
+
// otherwise "I changed my password" wouldn't actually lock anyone out for up
|
|
33906
|
+
// to 8 hours, which makes handover a false promise.
|
|
33907
|
+
const ver = (override && Number(override.tokenVersion)) || 0;
|
|
33908
|
+
const token = sign({ role: "admin", ver, exp }, secret);
|
|
33720
33909
|
res.setHeader(
|
|
33721
33910
|
"Set-Cookie",
|
|
33722
|
-
"sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 *
|
|
33911
|
+
"sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 2
|
|
33723
33912
|
);
|
|
33724
33913
|
res.status(200).json({ ok: true });
|
|
33725
33914
|
}
|
|
@@ -33861,7 +34050,7 @@ function readBody(req) {
|
|
|
33861
34050
|
});
|
|
33862
34051
|
}
|
|
33863
34052
|
|
|
33864
|
-
async function
|
|
34053
|
+
async function currentRecord() {
|
|
33865
34054
|
try {
|
|
33866
34055
|
const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
|
|
33867
34056
|
const target = blobs.find((b) => b.pathname === AUTH_KEY);
|
|
@@ -33869,13 +34058,13 @@ async function currentHash() {
|
|
|
33869
34058
|
const res = await fetch(target.url, { cache: "no-store" });
|
|
33870
34059
|
if (res.ok) {
|
|
33871
34060
|
const j = openAuth(await res.json());
|
|
33872
|
-
if (j && typeof j.passwordHash === "string" && j.passwordHash) return j
|
|
34061
|
+
if (j && typeof j.passwordHash === "string" && j.passwordHash) return j;
|
|
33873
34062
|
}
|
|
33874
34063
|
}
|
|
33875
34064
|
} catch {
|
|
33876
34065
|
// fall through to the env var
|
|
33877
34066
|
}
|
|
33878
|
-
return
|
|
34067
|
+
return null;
|
|
33879
34068
|
}
|
|
33880
34069
|
|
|
33881
34070
|
export default async function handler(req, res) {
|
|
@@ -33916,7 +34105,8 @@ export default async function handler(req, res) {
|
|
|
33916
34105
|
return;
|
|
33917
34106
|
}
|
|
33918
34107
|
|
|
33919
|
-
const
|
|
34108
|
+
const existingRecord = await currentRecord();
|
|
34109
|
+
const existing = existingRecord ? existingRecord.passwordHash : process.env.ADMIN_PASSWORD_HASH || "";
|
|
33920
34110
|
if (!existing) {
|
|
33921
34111
|
res.status(500).json({ error: "not_configured" });
|
|
33922
34112
|
return;
|
|
@@ -33933,7 +34123,13 @@ export default async function handler(req, res) {
|
|
|
33933
34123
|
try {
|
|
33934
34124
|
// clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
|
|
33935
34125
|
// the agency "your stored copy is no longer the live password".
|
|
33936
|
-
|
|
34126
|
+
const nextVersion = (Number(existingRecord && existingRecord.tokenVersion) || 0) + 1;
|
|
34127
|
+
await put(AUTH_KEY, sealAuth({
|
|
34128
|
+
passwordHash,
|
|
34129
|
+
updatedAt: new Date().toISOString(),
|
|
34130
|
+
clientChanged: true,
|
|
34131
|
+
tokenVersion: nextVersion,
|
|
34132
|
+
}), {
|
|
33937
34133
|
access: "public",
|
|
33938
34134
|
addRandomSuffix: false,
|
|
33939
34135
|
allowOverwrite: true,
|
|
@@ -33943,6 +34139,20 @@ export default async function handler(req, res) {
|
|
|
33943
34139
|
res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
|
|
33944
34140
|
return;
|
|
33945
34141
|
}
|
|
34142
|
+
// Clear the caller's own cookie too \u2014 their old token is now invalid, so
|
|
34143
|
+
// leaving it set would just produce confusing 401s until they reload.
|
|
34144
|
+
res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
|
|
34145
|
+
res.status(200).json({ ok: true, signedOut: true });
|
|
34146
|
+
}
|
|
34147
|
+
`;
|
|
34148
|
+
exports2.API_ADMIN_LOGOUT_JS = `export const config = { runtime: "nodejs" };
|
|
34149
|
+
|
|
34150
|
+
export default function handler(req, res) {
|
|
34151
|
+
if (req.method !== "POST") {
|
|
34152
|
+
res.status(405).json({ error: "method_not_allowed" });
|
|
34153
|
+
return;
|
|
34154
|
+
}
|
|
34155
|
+
res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
|
|
33946
34156
|
res.status(200).json({ ok: true });
|
|
33947
34157
|
}
|
|
33948
34158
|
`;
|
|
@@ -34727,7 +34937,11 @@ export default async function handler(req, res) {
|
|
|
34727
34937
|
}
|
|
34728
34938
|
|
|
34729
34939
|
function TabBar(props) {
|
|
34730
|
-
|
|
34940
|
+
async function signOut() {
|
|
34941
|
+
try { await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
|
|
34942
|
+
window.location.reload();
|
|
34943
|
+
}
|
|
34944
|
+
return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12, alignItems: "center" } },
|
|
34731
34945
|
props.tabs.map(function (t) {
|
|
34732
34946
|
var active = props.section[0] === t.id;
|
|
34733
34947
|
return h("button", {
|
|
@@ -34741,7 +34955,19 @@ export default async function handler(req, res) {
|
|
|
34741
34955
|
font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
|
|
34742
34956
|
}
|
|
34743
34957
|
}, t.label);
|
|
34744
|
-
})
|
|
34958
|
+
}).concat([
|
|
34959
|
+
// Without this, a shared or public computer keeps a live admin session.
|
|
34960
|
+
h("button", {
|
|
34961
|
+
key: "__signout",
|
|
34962
|
+
onClick: signOut,
|
|
34963
|
+
title: "Sign out",
|
|
34964
|
+
style: {
|
|
34965
|
+
marginLeft: "auto", background: "transparent", color: "var(--muted)",
|
|
34966
|
+
border: "1px solid var(--border)", borderRadius: 999, padding: "8px 14px",
|
|
34967
|
+
font: "inherit", fontSize: 12, cursor: "pointer"
|
|
34968
|
+
}
|
|
34969
|
+
}, "Sign out")
|
|
34970
|
+
])
|
|
34745
34971
|
);
|
|
34746
34972
|
}
|
|
34747
34973
|
|
|
@@ -34768,8 +34994,11 @@ export default async function handler(req, res) {
|
|
|
34768
34994
|
var data = null;
|
|
34769
34995
|
try { data = await res.json(); } catch (_) { data = null; }
|
|
34770
34996
|
if (res.ok) {
|
|
34771
|
-
msg[1]("Password changed
|
|
34997
|
+
msg[1]("Password changed \u2014 signing you back in\u2026");
|
|
34772
34998
|
cur[1](""); next[1](""); again[1]("");
|
|
34999
|
+
// The server invalidated this session, so stay logged in would just
|
|
35000
|
+
// produce confusing 401s. Reload straight into the login screen.
|
|
35001
|
+
setTimeout(function () { window.location.reload(); }, 1200);
|
|
34773
35002
|
} else if (res.status === 401) {
|
|
34774
35003
|
err[1]((data && data.error) === "wrong_current_password"
|
|
34775
35004
|
? "That's not your current password."
|
|
@@ -80691,6 +80920,15 @@ var require_builder = __commonJS({
|
|
|
80691
80920
|
db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
|
|
80692
80921
|
return uid;
|
|
80693
80922
|
}
|
|
80923
|
+
function reportKeyFor(leadId) {
|
|
80924
|
+
const db2 = (0, db_1.getDb)();
|
|
80925
|
+
const row = db2.get("SELECT report_key FROM leads WHERE id = ?", [leadId]);
|
|
80926
|
+
if (row?.report_key)
|
|
80927
|
+
return row.report_key;
|
|
80928
|
+
const key = (0, crypto_1.randomUUID)().replace(/-/g, "") + (0, crypto_1.randomUUID)().replace(/-/g, "");
|
|
80929
|
+
db2.run("UPDATE leads SET report_key = ? WHERE id = ?", [key, leadId]);
|
|
80930
|
+
return key;
|
|
80931
|
+
}
|
|
80694
80932
|
function authEncKeyFor(leadId) {
|
|
80695
80933
|
const db2 = (0, db_1.getDb)();
|
|
80696
80934
|
const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
|
|
@@ -81459,6 +81697,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81459
81697
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81460
81698
|
SESSION_SECRET: sessionSecret,
|
|
81461
81699
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81700
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81462
81701
|
BUSINESS_NAME: lead.name
|
|
81463
81702
|
});
|
|
81464
81703
|
const vercelJson = JSON.stringify({
|
|
@@ -81473,6 +81712,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81473
81712
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81474
81713
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81475
81714
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
81715
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81476
81716
|
{ file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
|
|
81477
81717
|
{ file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
|
|
81478
81718
|
{ file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
|
|
@@ -81498,7 +81738,11 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81498
81738
|
await setProjectEnv(projectName, vercelToken, {
|
|
81499
81739
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81500
81740
|
SITE_ID: siteUidFor(lead.id),
|
|
81501
|
-
BUSINESS_NAME: lead.name
|
|
81741
|
+
BUSINESS_NAME: lead.name,
|
|
81742
|
+
// Encrypts stats.json — the Blob is public and its path is guessable, so
|
|
81743
|
+
// without this the visit counts are readable by anyone who finds the URL.
|
|
81744
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81745
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81502
81746
|
});
|
|
81503
81747
|
}
|
|
81504
81748
|
const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
|
|
@@ -81583,7 +81827,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
|
|
|
81583
81827
|
const viteConfigCms = `import { defineConfig } from 'vite';
|
|
81584
81828
|
import react from '@vitejs/plugin-react';
|
|
81585
81829
|
export default defineConfig({ plugins: [react()] });`;
|
|
81586
|
-
filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
|
|
81830
|
+
filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
|
|
81587
81831
|
if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
|
|
81588
81832
|
throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
|
|
81589
81833
|
}
|
|
@@ -81594,6 +81838,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81594
81838
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81595
81839
|
SESSION_SECRET: sessionSecret,
|
|
81596
81840
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81841
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81597
81842
|
BUSINESS_NAME: lead.name
|
|
81598
81843
|
};
|
|
81599
81844
|
if (existingUid)
|
|
@@ -81693,7 +81938,8 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81693
81938
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81694
81939
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81695
81940
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81696
|
-
AUTH_ENC_KEY: authEncKeyFor(lead.id)
|
|
81941
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81942
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81697
81943
|
};
|
|
81698
81944
|
if (bookingExistingUid)
|
|
81699
81945
|
adminEnv.SITE_ID = bookingExistingUid;
|
|
@@ -81741,6 +81987,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81741
81987
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81742
81988
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81743
81989
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
81990
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81744
81991
|
{ file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
|
|
81745
81992
|
{ file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
|
|
81746
81993
|
{ file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
|
|
@@ -81789,7 +82036,8 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81789
82036
|
// Admin panel auth.
|
|
81790
82037
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81791
82038
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81792
|
-
AUTH_ENC_KEY: authEncKeyFor(lead.id)
|
|
82039
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
82040
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81793
82041
|
});
|
|
81794
82042
|
const files = [
|
|
81795
82043
|
{ file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
|
|
@@ -81803,6 +82051,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81803
82051
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81804
82052
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81805
82053
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
82054
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81806
82055
|
{ file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
|
|
81807
82056
|
{ file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
|
|
81808
82057
|
// Serve /admin without a trailing-slash redirect dance.
|
|
@@ -81986,12 +82235,13 @@ export default defineConfig({ plugins: [react()] });`,
|
|
|
81986
82235
|
]
|
|
81987
82236
|
}),
|
|
81988
82237
|
encoding: "utf-8"
|
|
81989
|
-
}, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
|
|
82238
|
+
}, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
|
|
81990
82239
|
const env = {
|
|
81991
82240
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81992
82241
|
ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
|
|
81993
82242
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81994
82243
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
82244
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81995
82245
|
BUSINESS_NAME: lead.name
|
|
81996
82246
|
};
|
|
81997
82247
|
if (siteUid)
|
|
@@ -83148,8 +83398,33 @@ var require_site_stats = __commonJS({
|
|
|
83148
83398
|
exports2.fetchSiteStats = fetchSiteStats;
|
|
83149
83399
|
exports2.pctChange = pctChange;
|
|
83150
83400
|
var axios_1 = __importDefault(require_axios());
|
|
83151
|
-
async function fetchSiteStats(siteUrl, adminPassword) {
|
|
83401
|
+
async function fetchSiteStats(siteUrl, adminPassword, reportKey) {
|
|
83152
83402
|
const base = siteUrl.replace(/\/$/, "");
|
|
83403
|
+
if (reportKey) {
|
|
83404
|
+
try {
|
|
83405
|
+
const res = await axios_1.default.get(`${base}/api/stats`, {
|
|
83406
|
+
timeout: 2e4,
|
|
83407
|
+
headers: { Authorization: `Bearer ${reportKey}` },
|
|
83408
|
+
validateStatus: () => true
|
|
83409
|
+
});
|
|
83410
|
+
if (res.status === 200)
|
|
83411
|
+
return { ok: true, data: res.data };
|
|
83412
|
+
if (res.status === 404) {
|
|
83413
|
+
return {
|
|
83414
|
+
ok: false,
|
|
83415
|
+
error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
|
|
83416
|
+
};
|
|
83417
|
+
}
|
|
83418
|
+
} catch (e) {
|
|
83419
|
+
return { ok: false, error: `Couldn't reach the site: ${e.message}` };
|
|
83420
|
+
}
|
|
83421
|
+
}
|
|
83422
|
+
if (!adminPassword) {
|
|
83423
|
+
return {
|
|
83424
|
+
ok: false,
|
|
83425
|
+
error: "No report key on this site and no stored admin password. Rebuild it (`sproutkit regenerate`) to provision a read-only report key."
|
|
83426
|
+
};
|
|
83427
|
+
}
|
|
83153
83428
|
let cookie;
|
|
83154
83429
|
try {
|
|
83155
83430
|
const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
|
|
@@ -95946,20 +96221,22 @@ Interested?`;
|
|
|
95946
96221
|
}
|
|
95947
96222
|
case "site_report": {
|
|
95948
96223
|
const id = Number(input.id);
|
|
95949
|
-
const lead = db2.get(
|
|
96224
|
+
const lead = db2.get(`SELECT id, name, site_url, booking_url, admin_url, admin_password,
|
|
96225
|
+
booking_admin_password, report_key
|
|
96226
|
+
FROM leads WHERE id = ?`, [id]);
|
|
95950
96227
|
if (!lead)
|
|
95951
96228
|
throw new Error(`Lead #${id} not found`);
|
|
95952
96229
|
const base = lead.site_url || lead.booking_url;
|
|
95953
96230
|
if (!base)
|
|
95954
96231
|
return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
|
|
95955
96232
|
const password = lead.admin_password || lead.booking_admin_password;
|
|
95956
|
-
if (!password) {
|
|
96233
|
+
if (!password && !lead.report_key) {
|
|
95957
96234
|
return {
|
|
95958
96235
|
ok: false,
|
|
95959
|
-
error: `No admin password stored for #${id}, so I can't read its stats.
|
|
96236
|
+
error: `No report key or admin password stored for #${id}, so I can't read its stats. Run \`sproutkit regenerate ${id}\` to provision a read-only report key.`
|
|
95960
96237
|
};
|
|
95961
96238
|
}
|
|
95962
|
-
const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
|
|
96239
|
+
const stats = await (0, site_stats_1.fetchSiteStats)(base, password, lead.report_key);
|
|
95963
96240
|
if (!stats.ok)
|
|
95964
96241
|
return { ok: false, error: stats.error, business: lead.name };
|
|
95965
96242
|
return { ok: true, id, business: lead.name, url: base, ...stats.data };
|
|
@@ -96997,7 +97274,7 @@ var require_package2 = __commonJS({
|
|
|
96997
97274
|
"package.json"(exports2, module2) {
|
|
96998
97275
|
module2.exports = {
|
|
96999
97276
|
name: "sproutkit-code",
|
|
97000
|
-
version: "1.
|
|
97277
|
+
version: "1.7.0",
|
|
97001
97278
|
description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
|
|
97002
97279
|
bin: {
|
|
97003
97280
|
sproutkit: "./dist/index.js"
|