sproutkit-code 1.6.1 → 1.8.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 +972 -36
- package/dist/skill/SKILL.md +11 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -225,6 +225,16 @@ 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",
|
|
232
|
+
// `sproutkit audit` findings: how bad the business's EXISTING site is, and
|
|
233
|
+
// the specific problems to lead outreach with. Distinct from lead_kind's
|
|
234
|
+
// absence-based gaps.
|
|
235
|
+
"site_score INTEGER",
|
|
236
|
+
"site_issues TEXT",
|
|
237
|
+
"audited_at TEXT",
|
|
228
238
|
// Reply loop: when we last contacted them, and the latest outcome. Kept on
|
|
229
239
|
// the lead (as well as contact_log) so `followups` is a single cheap query.
|
|
230
240
|
"last_contacted_at TEXT",
|
|
@@ -31319,6 +31329,7 @@ var require_analytics_runtime = __commonJS({
|
|
|
31319
31329
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
31320
31330
|
exports2.TRACK_SNIPPET = exports2.API_STATS_JS = exports2.API_TRACK_JS = void 0;
|
|
31321
31331
|
exports2.API_TRACK_JS = `import { list, put } from "@vercel/blob";
|
|
31332
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
31322
31333
|
|
|
31323
31334
|
export const config = { runtime: "nodejs" };
|
|
31324
31335
|
|
|
@@ -31333,6 +31344,54 @@ const EVENTS = ["view", "call", "book", "directions", "email", "menu"];
|
|
|
31333
31344
|
// the file has to stay small enough to read on every request.
|
|
31334
31345
|
const MAX_DAYS = 400;
|
|
31335
31346
|
|
|
31347
|
+
|
|
31348
|
+
// stats.json lives in a PUBLIC Blob at a non-random path, and the store
|
|
31349
|
+
// hostname leaks through every photo URL on the site \u2014 so the file's URL must
|
|
31350
|
+
// be assumed guessable. Visitor counts are the client's commercial data and the
|
|
31351
|
+
// agency's renewal evidence, so the contents are encrypted with AES-256-GCM
|
|
31352
|
+
// under AUTH_ENC_KEY (a server-side env var). A direct public read yields
|
|
31353
|
+
// opaque bytes instead of the numbers.
|
|
31354
|
+
function statsKey() {
|
|
31355
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31356
|
+
if (!material) return null;
|
|
31357
|
+
return createHash("sha256").update("sproutkit-stats:" + material).digest();
|
|
31358
|
+
}
|
|
31359
|
+
|
|
31360
|
+
function sealStats(obj) {
|
|
31361
|
+
const key = statsKey();
|
|
31362
|
+
if (!key) return JSON.stringify({ v: 0, plain: obj });
|
|
31363
|
+
const iv = randomBytes(12);
|
|
31364
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
31365
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
|
|
31366
|
+
return JSON.stringify({
|
|
31367
|
+
v: 1,
|
|
31368
|
+
iv: iv.toString("base64"),
|
|
31369
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
31370
|
+
ct: ct.toString("base64"),
|
|
31371
|
+
});
|
|
31372
|
+
}
|
|
31373
|
+
|
|
31374
|
+
function openStats(raw) {
|
|
31375
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31376
|
+
// Unencrypted shapes: v0 (no key available) and pre-encryption files that
|
|
31377
|
+
// stored { days } directly. Both must keep working.
|
|
31378
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31379
|
+
if (raw.days && raw.v === undefined) return raw;
|
|
31380
|
+
if (raw.v !== 1) return null;
|
|
31381
|
+
const key = statsKey();
|
|
31382
|
+
if (!key) return null;
|
|
31383
|
+
try {
|
|
31384
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31385
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31386
|
+
return JSON.parse(
|
|
31387
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31388
|
+
);
|
|
31389
|
+
} catch {
|
|
31390
|
+
// Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
|
|
31391
|
+
return null;
|
|
31392
|
+
}
|
|
31393
|
+
}
|
|
31394
|
+
|
|
31336
31395
|
function today() {
|
|
31337
31396
|
return new Date().toISOString().slice(0, 10);
|
|
31338
31397
|
}
|
|
@@ -31344,7 +31403,7 @@ async function readStats() {
|
|
|
31344
31403
|
if (!target) return { days: {} };
|
|
31345
31404
|
const res = await fetch(target.url, { cache: "no-store" });
|
|
31346
31405
|
if (!res.ok) return { days: {} };
|
|
31347
|
-
const j = await res.json();
|
|
31406
|
+
const j = openStats(await res.json());
|
|
31348
31407
|
return j && typeof j === "object" && j.days ? j : { days: {} };
|
|
31349
31408
|
} catch {
|
|
31350
31409
|
return { days: {} };
|
|
@@ -31391,7 +31450,7 @@ export default async function handler(req, res) {
|
|
|
31391
31450
|
}
|
|
31392
31451
|
|
|
31393
31452
|
try {
|
|
31394
|
-
await put(STATS_KEY,
|
|
31453
|
+
await put(STATS_KEY, sealStats(stats), {
|
|
31395
31454
|
access: "public",
|
|
31396
31455
|
addRandomSuffix: false,
|
|
31397
31456
|
allowOverwrite: true,
|
|
@@ -31404,12 +31463,110 @@ export default async function handler(req, res) {
|
|
|
31404
31463
|
}
|
|
31405
31464
|
`;
|
|
31406
31465
|
exports2.API_STATS_JS = `import { list } from "@vercel/blob";
|
|
31407
|
-
import {
|
|
31466
|
+
import {
|
|
31467
|
+
createHmac,
|
|
31468
|
+
timingSafeEqual,
|
|
31469
|
+
createHash,
|
|
31470
|
+
createCipheriv,
|
|
31471
|
+
createDecipheriv,
|
|
31472
|
+
randomBytes,
|
|
31473
|
+
} from "node:crypto";
|
|
31408
31474
|
|
|
31409
31475
|
export const config = { runtime: "nodejs" };
|
|
31410
31476
|
|
|
31411
31477
|
const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
|
|
31412
31478
|
const STATS_KEY = PFX + "stats.json";
|
|
31479
|
+
const AUTH_KEY = PFX + "admin-auth.json";
|
|
31480
|
+
|
|
31481
|
+
|
|
31482
|
+
// stats.json lives in a PUBLIC Blob at a non-random path, and the store
|
|
31483
|
+
// hostname leaks through every photo URL on the site \u2014 so the file's URL must
|
|
31484
|
+
// be assumed guessable. Visitor counts are the client's commercial data and the
|
|
31485
|
+
// agency's renewal evidence, so the contents are encrypted with AES-256-GCM
|
|
31486
|
+
// under AUTH_ENC_KEY (a server-side env var). A direct public read yields
|
|
31487
|
+
// opaque bytes instead of the numbers.
|
|
31488
|
+
function statsKey() {
|
|
31489
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31490
|
+
if (!material) return null;
|
|
31491
|
+
return createHash("sha256").update("sproutkit-stats:" + material).digest();
|
|
31492
|
+
}
|
|
31493
|
+
|
|
31494
|
+
function sealStats(obj) {
|
|
31495
|
+
const key = statsKey();
|
|
31496
|
+
if (!key) return JSON.stringify({ v: 0, plain: obj });
|
|
31497
|
+
const iv = randomBytes(12);
|
|
31498
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
31499
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
|
|
31500
|
+
return JSON.stringify({
|
|
31501
|
+
v: 1,
|
|
31502
|
+
iv: iv.toString("base64"),
|
|
31503
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
31504
|
+
ct: ct.toString("base64"),
|
|
31505
|
+
});
|
|
31506
|
+
}
|
|
31507
|
+
|
|
31508
|
+
function openStats(raw) {
|
|
31509
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31510
|
+
// Unencrypted shapes: v0 (no key available) and pre-encryption files that
|
|
31511
|
+
// stored { days } directly. Both must keep working.
|
|
31512
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31513
|
+
if (raw.days && raw.v === undefined) return raw;
|
|
31514
|
+
if (raw.v !== 1) return null;
|
|
31515
|
+
const key = statsKey();
|
|
31516
|
+
if (!key) return null;
|
|
31517
|
+
try {
|
|
31518
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31519
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31520
|
+
return JSON.parse(
|
|
31521
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31522
|
+
);
|
|
31523
|
+
} catch {
|
|
31524
|
+
// Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
|
|
31525
|
+
return null;
|
|
31526
|
+
}
|
|
31527
|
+
}
|
|
31528
|
+
|
|
31529
|
+
|
|
31530
|
+
// Tokens carry the credential version they were minted under. A password change
|
|
31531
|
+
// bumps it, so cookies issued earlier stop working \u2014 without this, "I changed my
|
|
31532
|
+
// password" wouldn't revoke anyone for up to 8 hours, and the client couldn't
|
|
31533
|
+
// actually take their site back from the agency.
|
|
31534
|
+
//
|
|
31535
|
+
// Fails OPEN when the blob is unreadable: a Blob hiccup must not lock an owner
|
|
31536
|
+
// out of their own site. The password itself is still required to get a token.
|
|
31537
|
+
async function tokenVersionOk(data) {
|
|
31538
|
+
try {
|
|
31539
|
+
const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
|
|
31540
|
+
const target = blobs.find((b) => b.pathname === AUTH_KEY);
|
|
31541
|
+
if (!target) return true;
|
|
31542
|
+
const res = await fetch(target.url, { cache: "no-store" });
|
|
31543
|
+
if (!res.ok) return true;
|
|
31544
|
+
const rec = openStatsRecord(await res.json());
|
|
31545
|
+
const current = rec && Number(rec.tokenVersion) ? Number(rec.tokenVersion) : 0;
|
|
31546
|
+
return (Number(data && data.ver) || 0) >= current;
|
|
31547
|
+
} catch {
|
|
31548
|
+
return true;
|
|
31549
|
+
}
|
|
31550
|
+
}
|
|
31551
|
+
|
|
31552
|
+
// admin-auth.json is sealed with a different key label than stats.json.
|
|
31553
|
+
function openStatsRecord(raw) {
|
|
31554
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31555
|
+
if (raw.v === 0 && raw.plain) return raw.plain;
|
|
31556
|
+
if (raw.v !== 1) return null;
|
|
31557
|
+
const material = process.env.AUTH_ENC_KEY || "";
|
|
31558
|
+
if (!material) return null;
|
|
31559
|
+
try {
|
|
31560
|
+
const key = createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
|
|
31561
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
|
|
31562
|
+
decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
|
|
31563
|
+
return JSON.parse(
|
|
31564
|
+
Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
|
|
31565
|
+
);
|
|
31566
|
+
} catch {
|
|
31567
|
+
return null;
|
|
31568
|
+
}
|
|
31569
|
+
}
|
|
31413
31570
|
|
|
31414
31571
|
function verifySession(req) {
|
|
31415
31572
|
const cookie = req.headers.cookie || "";
|
|
@@ -31427,7 +31584,8 @@ function verifySession(req) {
|
|
|
31427
31584
|
const data = JSON.parse(
|
|
31428
31585
|
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
31429
31586
|
);
|
|
31430
|
-
|
|
31587
|
+
if (!(typeof data.exp === "number" && data.exp > Date.now() / 1000)) return false;
|
|
31588
|
+
return data;
|
|
31431
31589
|
} catch {
|
|
31432
31590
|
return false;
|
|
31433
31591
|
}
|
|
@@ -31450,8 +31608,32 @@ function shiftDays(n) {
|
|
|
31450
31608
|
return d.toISOString().slice(0, 10);
|
|
31451
31609
|
}
|
|
31452
31610
|
|
|
31611
|
+
// The agency needs these numbers for renewal conversations, but must NOT need
|
|
31612
|
+
// the client's password to get them \u2014 otherwise the agency permanently holds the
|
|
31613
|
+
// client's login, and the client changing it silently breaks reporting.
|
|
31614
|
+
//
|
|
31615
|
+
// REPORT_KEY is a separate, per-site, read-only credential. It is accepted ONLY
|
|
31616
|
+
// here: it cannot edit content, read bookings, or change the password. So handing
|
|
31617
|
+
// the client full ownership and keeping agency reporting are no longer in
|
|
31618
|
+
// conflict.
|
|
31619
|
+
function verifyReportKey(req) {
|
|
31620
|
+
const expected = process.env.REPORT_KEY;
|
|
31621
|
+
if (!expected) return false;
|
|
31622
|
+
const header = req.headers["authorization"] || req.headers["x-report-key"] || "";
|
|
31623
|
+
const provided = String(header).replace(/^Bearer\\s+/i, "");
|
|
31624
|
+
if (!provided || provided.length !== expected.length) return false;
|
|
31625
|
+
try {
|
|
31626
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
31627
|
+
} catch {
|
|
31628
|
+
return false;
|
|
31629
|
+
}
|
|
31630
|
+
}
|
|
31631
|
+
|
|
31453
31632
|
export default async function handler(req, res) {
|
|
31454
|
-
|
|
31633
|
+
const claims = verifySession(req);
|
|
31634
|
+
const viaSession = claims ? await tokenVersionOk(claims) : false;
|
|
31635
|
+
const viaReportKey = viaSession ? false : verifyReportKey(req);
|
|
31636
|
+
if (!viaSession && !viaReportKey) {
|
|
31455
31637
|
res.status(401).json({ error: "unauthorized" });
|
|
31456
31638
|
return;
|
|
31457
31639
|
}
|
|
@@ -31462,7 +31644,7 @@ export default async function handler(req, res) {
|
|
|
31462
31644
|
if (target) {
|
|
31463
31645
|
const r = await fetch(target.url, { cache: "no-store" });
|
|
31464
31646
|
if (r.ok) {
|
|
31465
|
-
const j = await r.json();
|
|
31647
|
+
const j = openStats(await r.json());
|
|
31466
31648
|
if (j && j.days) stats = j;
|
|
31467
31649
|
}
|
|
31468
31650
|
}
|
|
@@ -31473,6 +31655,7 @@ export default async function handler(req, res) {
|
|
|
31473
31655
|
const today = shiftDays(0);
|
|
31474
31656
|
res.setHeader("Cache-Control", "no-store");
|
|
31475
31657
|
res.status(200).json({
|
|
31658
|
+
via: viaSession ? "owner" : "report_key",
|
|
31476
31659
|
last30: windowTotals(stats.days, shiftDays(-29), today),
|
|
31477
31660
|
// The comparison window is what makes the number mean something.
|
|
31478
31661
|
prev30: windowTotals(stats.days, shiftDays(-59), shiftDays(-30)),
|
|
@@ -33339,7 +33522,7 @@ var require_cms_runtime = __commonJS({
|
|
|
33339
33522
|
};
|
|
33340
33523
|
})();
|
|
33341
33524
|
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;
|
|
33525
|
+
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
33526
|
exports2.generateFriendlyPassword = generateFriendlyPassword;
|
|
33344
33527
|
exports2.hashPassword = hashPassword;
|
|
33345
33528
|
exports2.generateSessionSecret = generateSessionSecret;
|
|
@@ -33715,11 +33898,23 @@ export default async function handler(req, res) {
|
|
|
33715
33898
|
delete throttle[who];
|
|
33716
33899
|
await writeJson(THROTTLE_KEY, throttle);
|
|
33717
33900
|
}
|
|
33718
|
-
|
|
33719
|
-
|
|
33901
|
+
// 2h, not 8h. /api/stats checks the credential version and revokes instantly,
|
|
33902
|
+
// but the content/upload/booking endpoints don't (each is a standalone
|
|
33903
|
+
// serverless file and adding the check to all of them means duplicating the
|
|
33904
|
+
// auth-blob crypto five more times). So a cookie issued before a password
|
|
33905
|
+
// change can still WRITE for up to this long. Two hours keeps that window
|
|
33906
|
+
// small; the honest fix is a shared auth module, which needs a bundler for the
|
|
33907
|
+
// generated site.
|
|
33908
|
+
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 2;
|
|
33909
|
+
// Stamp the credential version into the token. Changing the password bumps
|
|
33910
|
+
// this, which instantly invalidates every cookie issued before the change \u2014
|
|
33911
|
+
// otherwise "I changed my password" wouldn't actually lock anyone out for up
|
|
33912
|
+
// to 8 hours, which makes handover a false promise.
|
|
33913
|
+
const ver = (override && Number(override.tokenVersion)) || 0;
|
|
33914
|
+
const token = sign({ role: "admin", ver, exp }, secret);
|
|
33720
33915
|
res.setHeader(
|
|
33721
33916
|
"Set-Cookie",
|
|
33722
|
-
"sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 *
|
|
33917
|
+
"sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 2
|
|
33723
33918
|
);
|
|
33724
33919
|
res.status(200).json({ ok: true });
|
|
33725
33920
|
}
|
|
@@ -33861,7 +34056,7 @@ function readBody(req) {
|
|
|
33861
34056
|
});
|
|
33862
34057
|
}
|
|
33863
34058
|
|
|
33864
|
-
async function
|
|
34059
|
+
async function currentRecord() {
|
|
33865
34060
|
try {
|
|
33866
34061
|
const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
|
|
33867
34062
|
const target = blobs.find((b) => b.pathname === AUTH_KEY);
|
|
@@ -33869,13 +34064,13 @@ async function currentHash() {
|
|
|
33869
34064
|
const res = await fetch(target.url, { cache: "no-store" });
|
|
33870
34065
|
if (res.ok) {
|
|
33871
34066
|
const j = openAuth(await res.json());
|
|
33872
|
-
if (j && typeof j.passwordHash === "string" && j.passwordHash) return j
|
|
34067
|
+
if (j && typeof j.passwordHash === "string" && j.passwordHash) return j;
|
|
33873
34068
|
}
|
|
33874
34069
|
}
|
|
33875
34070
|
} catch {
|
|
33876
34071
|
// fall through to the env var
|
|
33877
34072
|
}
|
|
33878
|
-
return
|
|
34073
|
+
return null;
|
|
33879
34074
|
}
|
|
33880
34075
|
|
|
33881
34076
|
export default async function handler(req, res) {
|
|
@@ -33916,7 +34111,8 @@ export default async function handler(req, res) {
|
|
|
33916
34111
|
return;
|
|
33917
34112
|
}
|
|
33918
34113
|
|
|
33919
|
-
const
|
|
34114
|
+
const existingRecord = await currentRecord();
|
|
34115
|
+
const existing = existingRecord ? existingRecord.passwordHash : process.env.ADMIN_PASSWORD_HASH || "";
|
|
33920
34116
|
if (!existing) {
|
|
33921
34117
|
res.status(500).json({ error: "not_configured" });
|
|
33922
34118
|
return;
|
|
@@ -33933,7 +34129,13 @@ export default async function handler(req, res) {
|
|
|
33933
34129
|
try {
|
|
33934
34130
|
// clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
|
|
33935
34131
|
// the agency "your stored copy is no longer the live password".
|
|
33936
|
-
|
|
34132
|
+
const nextVersion = (Number(existingRecord && existingRecord.tokenVersion) || 0) + 1;
|
|
34133
|
+
await put(AUTH_KEY, sealAuth({
|
|
34134
|
+
passwordHash,
|
|
34135
|
+
updatedAt: new Date().toISOString(),
|
|
34136
|
+
clientChanged: true,
|
|
34137
|
+
tokenVersion: nextVersion,
|
|
34138
|
+
}), {
|
|
33937
34139
|
access: "public",
|
|
33938
34140
|
addRandomSuffix: false,
|
|
33939
34141
|
allowOverwrite: true,
|
|
@@ -33943,6 +34145,20 @@ export default async function handler(req, res) {
|
|
|
33943
34145
|
res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
|
|
33944
34146
|
return;
|
|
33945
34147
|
}
|
|
34148
|
+
// Clear the caller's own cookie too \u2014 their old token is now invalid, so
|
|
34149
|
+
// leaving it set would just produce confusing 401s until they reload.
|
|
34150
|
+
res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
|
|
34151
|
+
res.status(200).json({ ok: true, signedOut: true });
|
|
34152
|
+
}
|
|
34153
|
+
`;
|
|
34154
|
+
exports2.API_ADMIN_LOGOUT_JS = `export const config = { runtime: "nodejs" };
|
|
34155
|
+
|
|
34156
|
+
export default function handler(req, res) {
|
|
34157
|
+
if (req.method !== "POST") {
|
|
34158
|
+
res.status(405).json({ error: "method_not_allowed" });
|
|
34159
|
+
return;
|
|
34160
|
+
}
|
|
34161
|
+
res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
|
|
33946
34162
|
res.status(200).json({ ok: true });
|
|
33947
34163
|
}
|
|
33948
34164
|
`;
|
|
@@ -34727,7 +34943,11 @@ export default async function handler(req, res) {
|
|
|
34727
34943
|
}
|
|
34728
34944
|
|
|
34729
34945
|
function TabBar(props) {
|
|
34730
|
-
|
|
34946
|
+
async function signOut() {
|
|
34947
|
+
try { await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
|
|
34948
|
+
window.location.reload();
|
|
34949
|
+
}
|
|
34950
|
+
return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12, alignItems: "center" } },
|
|
34731
34951
|
props.tabs.map(function (t) {
|
|
34732
34952
|
var active = props.section[0] === t.id;
|
|
34733
34953
|
return h("button", {
|
|
@@ -34741,7 +34961,19 @@ export default async function handler(req, res) {
|
|
|
34741
34961
|
font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
|
|
34742
34962
|
}
|
|
34743
34963
|
}, t.label);
|
|
34744
|
-
})
|
|
34964
|
+
}).concat([
|
|
34965
|
+
// Without this, a shared or public computer keeps a live admin session.
|
|
34966
|
+
h("button", {
|
|
34967
|
+
key: "__signout",
|
|
34968
|
+
onClick: signOut,
|
|
34969
|
+
title: "Sign out",
|
|
34970
|
+
style: {
|
|
34971
|
+
marginLeft: "auto", background: "transparent", color: "var(--muted)",
|
|
34972
|
+
border: "1px solid var(--border)", borderRadius: 999, padding: "8px 14px",
|
|
34973
|
+
font: "inherit", fontSize: 12, cursor: "pointer"
|
|
34974
|
+
}
|
|
34975
|
+
}, "Sign out")
|
|
34976
|
+
])
|
|
34745
34977
|
);
|
|
34746
34978
|
}
|
|
34747
34979
|
|
|
@@ -34768,8 +35000,11 @@ export default async function handler(req, res) {
|
|
|
34768
35000
|
var data = null;
|
|
34769
35001
|
try { data = await res.json(); } catch (_) { data = null; }
|
|
34770
35002
|
if (res.ok) {
|
|
34771
|
-
msg[1]("Password changed
|
|
35003
|
+
msg[1]("Password changed \u2014 signing you back in\u2026");
|
|
34772
35004
|
cur[1](""); next[1](""); again[1]("");
|
|
35005
|
+
// The server invalidated this session, so stay logged in would just
|
|
35006
|
+
// produce confusing 401s. Reload straight into the login screen.
|
|
35007
|
+
setTimeout(function () { window.location.reload(); }, 1200);
|
|
34773
35008
|
} else if (res.status === 401) {
|
|
34774
35009
|
err[1]((data && data.error) === "wrong_current_password"
|
|
34775
35010
|
? "That's not your current password."
|
|
@@ -80691,6 +80926,15 @@ var require_builder = __commonJS({
|
|
|
80691
80926
|
db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
|
|
80692
80927
|
return uid;
|
|
80693
80928
|
}
|
|
80929
|
+
function reportKeyFor(leadId) {
|
|
80930
|
+
const db2 = (0, db_1.getDb)();
|
|
80931
|
+
const row = db2.get("SELECT report_key FROM leads WHERE id = ?", [leadId]);
|
|
80932
|
+
if (row?.report_key)
|
|
80933
|
+
return row.report_key;
|
|
80934
|
+
const key = (0, crypto_1.randomUUID)().replace(/-/g, "") + (0, crypto_1.randomUUID)().replace(/-/g, "");
|
|
80935
|
+
db2.run("UPDATE leads SET report_key = ? WHERE id = ?", [key, leadId]);
|
|
80936
|
+
return key;
|
|
80937
|
+
}
|
|
80694
80938
|
function authEncKeyFor(leadId) {
|
|
80695
80939
|
const db2 = (0, db_1.getDb)();
|
|
80696
80940
|
const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
|
|
@@ -81459,6 +81703,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81459
81703
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81460
81704
|
SESSION_SECRET: sessionSecret,
|
|
81461
81705
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81706
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81462
81707
|
BUSINESS_NAME: lead.name
|
|
81463
81708
|
});
|
|
81464
81709
|
const vercelJson = JSON.stringify({
|
|
@@ -81473,6 +81718,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81473
81718
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81474
81719
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81475
81720
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
81721
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81476
81722
|
{ file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
|
|
81477
81723
|
{ file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
|
|
81478
81724
|
{ file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
|
|
@@ -81498,7 +81744,11 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81498
81744
|
await setProjectEnv(projectName, vercelToken, {
|
|
81499
81745
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81500
81746
|
SITE_ID: siteUidFor(lead.id),
|
|
81501
|
-
BUSINESS_NAME: lead.name
|
|
81747
|
+
BUSINESS_NAME: lead.name,
|
|
81748
|
+
// Encrypts stats.json — the Blob is public and its path is guessable, so
|
|
81749
|
+
// without this the visit counts are readable by anyone who finds the URL.
|
|
81750
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81751
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81502
81752
|
});
|
|
81503
81753
|
}
|
|
81504
81754
|
const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
|
|
@@ -81583,7 +81833,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
|
|
|
81583
81833
|
const viteConfigCms = `import { defineConfig } from 'vite';
|
|
81584
81834
|
import react from '@vitejs/plugin-react';
|
|
81585
81835
|
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" });
|
|
81836
|
+
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
81837
|
if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
|
|
81588
81838
|
throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
|
|
81589
81839
|
}
|
|
@@ -81594,6 +81844,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81594
81844
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81595
81845
|
SESSION_SECRET: sessionSecret,
|
|
81596
81846
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81847
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81597
81848
|
BUSINESS_NAME: lead.name
|
|
81598
81849
|
};
|
|
81599
81850
|
if (existingUid)
|
|
@@ -81693,7 +81944,8 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81693
81944
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81694
81945
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81695
81946
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81696
|
-
AUTH_ENC_KEY: authEncKeyFor(lead.id)
|
|
81947
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
81948
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81697
81949
|
};
|
|
81698
81950
|
if (bookingExistingUid)
|
|
81699
81951
|
adminEnv.SITE_ID = bookingExistingUid;
|
|
@@ -81741,6 +81993,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81741
81993
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81742
81994
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81743
81995
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
81996
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81744
81997
|
{ file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
|
|
81745
81998
|
{ file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
|
|
81746
81999
|
{ file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
|
|
@@ -81789,7 +82042,8 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81789
82042
|
// Admin panel auth.
|
|
81790
82043
|
ADMIN_PASSWORD_HASH: adminHash,
|
|
81791
82044
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81792
|
-
AUTH_ENC_KEY: authEncKeyFor(lead.id)
|
|
82045
|
+
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
82046
|
+
REPORT_KEY: reportKeyFor(lead.id)
|
|
81793
82047
|
});
|
|
81794
82048
|
const files = [
|
|
81795
82049
|
{ file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
|
|
@@ -81803,6 +82057,7 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
81803
82057
|
{ file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
|
|
81804
82058
|
{ file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
|
|
81805
82059
|
{ file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
|
|
82060
|
+
{ file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
|
|
81806
82061
|
{ file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
|
|
81807
82062
|
{ file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
|
|
81808
82063
|
// Serve /admin without a trailing-slash redirect dance.
|
|
@@ -81986,12 +82241,13 @@ export default defineConfig({ plugins: [react()] });`,
|
|
|
81986
82241
|
]
|
|
81987
82242
|
}),
|
|
81988
82243
|
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" });
|
|
82244
|
+
}, { 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
82245
|
const env = {
|
|
81991
82246
|
BLOB_READ_WRITE_TOKEN: blobToken,
|
|
81992
82247
|
ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
|
|
81993
82248
|
SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
|
|
81994
82249
|
AUTH_ENC_KEY: authEncKeyFor(lead.id),
|
|
82250
|
+
REPORT_KEY: reportKeyFor(lead.id),
|
|
81995
82251
|
BUSINESS_NAME: lead.name
|
|
81996
82252
|
};
|
|
81997
82253
|
if (siteUid)
|
|
@@ -82540,6 +82796,389 @@ var require_booking_detect = __commonJS({
|
|
|
82540
82796
|
}
|
|
82541
82797
|
});
|
|
82542
82798
|
|
|
82799
|
+
// ../desktop/dist/main/lib/site-audit.js
|
|
82800
|
+
var require_site_audit = __commonJS({
|
|
82801
|
+
"../desktop/dist/main/lib/site-audit.js"(exports2) {
|
|
82802
|
+
"use strict";
|
|
82803
|
+
var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
|
|
82804
|
+
if (k2 === void 0) k2 = k;
|
|
82805
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
82806
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
82807
|
+
desc = { enumerable: true, get: function() {
|
|
82808
|
+
return m[k];
|
|
82809
|
+
} };
|
|
82810
|
+
}
|
|
82811
|
+
Object.defineProperty(o, k2, desc);
|
|
82812
|
+
}) : (function(o, m, k, k2) {
|
|
82813
|
+
if (k2 === void 0) k2 = k;
|
|
82814
|
+
o[k2] = m[k];
|
|
82815
|
+
}));
|
|
82816
|
+
var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
|
|
82817
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
82818
|
+
}) : function(o, v) {
|
|
82819
|
+
o["default"] = v;
|
|
82820
|
+
});
|
|
82821
|
+
var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
|
|
82822
|
+
var ownKeys = function(o) {
|
|
82823
|
+
ownKeys = Object.getOwnPropertyNames || function(o2) {
|
|
82824
|
+
var ar = [];
|
|
82825
|
+
for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
|
|
82826
|
+
return ar;
|
|
82827
|
+
};
|
|
82828
|
+
return ownKeys(o);
|
|
82829
|
+
};
|
|
82830
|
+
return function(mod) {
|
|
82831
|
+
if (mod && mod.__esModule) return mod;
|
|
82832
|
+
var result = {};
|
|
82833
|
+
if (mod != null) {
|
|
82834
|
+
for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
82835
|
+
}
|
|
82836
|
+
__setModuleDefault(result, mod);
|
|
82837
|
+
return result;
|
|
82838
|
+
};
|
|
82839
|
+
})();
|
|
82840
|
+
var __importDefault = exports2 && exports2.__importDefault || function(mod) {
|
|
82841
|
+
return mod && mod.__esModule ? mod : { "default": mod };
|
|
82842
|
+
};
|
|
82843
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
82844
|
+
exports2.auditSite = auditSite;
|
|
82845
|
+
exports2.isRebuildProspect = isRebuildProspect;
|
|
82846
|
+
exports2.headlineIssues = headlineIssues;
|
|
82847
|
+
var axios_1 = __importDefault(require_axios());
|
|
82848
|
+
var tls = __importStar(require("tls"));
|
|
82849
|
+
var url_1 = require("url");
|
|
82850
|
+
var WEIGHT = {
|
|
82851
|
+
critical: 30,
|
|
82852
|
+
major: 15,
|
|
82853
|
+
minor: 6
|
|
82854
|
+
};
|
|
82855
|
+
async function checkTls(hostname) {
|
|
82856
|
+
return new Promise((resolve) => {
|
|
82857
|
+
let settled = false;
|
|
82858
|
+
const done = (r) => {
|
|
82859
|
+
if (settled)
|
|
82860
|
+
return;
|
|
82861
|
+
settled = true;
|
|
82862
|
+
socket.destroy();
|
|
82863
|
+
resolve(r);
|
|
82864
|
+
};
|
|
82865
|
+
const socket = tls.connect({ host: hostname, port: 443, servername: hostname, timeout: 12e3 }, () => {
|
|
82866
|
+
if (!socket.authorized) {
|
|
82867
|
+
done({
|
|
82868
|
+
ok: false,
|
|
82869
|
+
issue: {
|
|
82870
|
+
code: "tls_invalid",
|
|
82871
|
+
label: "The security certificate isn\u2019t valid \u2014 browsers warn visitors before they can see the site",
|
|
82872
|
+
severity: "critical",
|
|
82873
|
+
detail: socket.authorizationError ? String(socket.authorizationError) : void 0
|
|
82874
|
+
}
|
|
82875
|
+
});
|
|
82876
|
+
return;
|
|
82877
|
+
}
|
|
82878
|
+
const cert = socket.getPeerCertificate();
|
|
82879
|
+
if (!cert || !cert.valid_to) {
|
|
82880
|
+
done({ ok: true });
|
|
82881
|
+
return;
|
|
82882
|
+
}
|
|
82883
|
+
const days = Math.round((new Date(cert.valid_to).getTime() - Date.now()) / 864e5);
|
|
82884
|
+
if (days < 0) {
|
|
82885
|
+
done({
|
|
82886
|
+
ok: false,
|
|
82887
|
+
daysToExpiry: days,
|
|
82888
|
+
issue: {
|
|
82889
|
+
code: "tls_expired",
|
|
82890
|
+
label: "The security certificate has expired \u2014 visitors see a full-page warning",
|
|
82891
|
+
severity: "critical",
|
|
82892
|
+
detail: `expired ${Math.abs(days)} days ago`
|
|
82893
|
+
}
|
|
82894
|
+
});
|
|
82895
|
+
return;
|
|
82896
|
+
}
|
|
82897
|
+
if (days <= 14) {
|
|
82898
|
+
done({
|
|
82899
|
+
ok: true,
|
|
82900
|
+
daysToExpiry: days,
|
|
82901
|
+
issue: {
|
|
82902
|
+
code: "tls_expiring",
|
|
82903
|
+
label: "The security certificate expires within two weeks",
|
|
82904
|
+
severity: "major",
|
|
82905
|
+
detail: `${days} days left`
|
|
82906
|
+
}
|
|
82907
|
+
});
|
|
82908
|
+
return;
|
|
82909
|
+
}
|
|
82910
|
+
done({ ok: true, daysToExpiry: days });
|
|
82911
|
+
});
|
|
82912
|
+
socket.on("error", (err) => {
|
|
82913
|
+
const msg = err.message || "";
|
|
82914
|
+
const code = err.code ?? "";
|
|
82915
|
+
let issue;
|
|
82916
|
+
if (/certificate has expired|CERT_HAS_EXPIRED/i.test(msg + code)) {
|
|
82917
|
+
issue = {
|
|
82918
|
+
code: "tls_expired",
|
|
82919
|
+
label: "The security certificate has expired \u2014 visitors see a full-page browser warning before they reach the site",
|
|
82920
|
+
severity: "critical",
|
|
82921
|
+
detail: msg
|
|
82922
|
+
};
|
|
82923
|
+
} else if (/self.signed|SELF_SIGNED/i.test(msg + code)) {
|
|
82924
|
+
issue = {
|
|
82925
|
+
code: "tls_self_signed",
|
|
82926
|
+
label: "The security certificate isn\u2019t from a recognised authority, so browsers warn visitors away",
|
|
82927
|
+
severity: "critical",
|
|
82928
|
+
detail: msg
|
|
82929
|
+
};
|
|
82930
|
+
} else if (/altnames|hostname\/ip does not match|ERR_TLS_CERT_ALTNAME/i.test(msg + code)) {
|
|
82931
|
+
issue = {
|
|
82932
|
+
code: "tls_wrong_host",
|
|
82933
|
+
label: "The security certificate was issued for a different domain, so browsers reject it",
|
|
82934
|
+
severity: "critical",
|
|
82935
|
+
detail: msg
|
|
82936
|
+
};
|
|
82937
|
+
} else if (/ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ECONNRESET/i.test(msg + code)) {
|
|
82938
|
+
issue = {
|
|
82939
|
+
code: "tls_unavailable",
|
|
82940
|
+
label: "The site has no working HTTPS \u2014 Chrome labels it \u201CNot secure\u201D to every visitor",
|
|
82941
|
+
severity: "critical",
|
|
82942
|
+
detail: msg
|
|
82943
|
+
};
|
|
82944
|
+
} else {
|
|
82945
|
+
issue = {
|
|
82946
|
+
code: "tls_error",
|
|
82947
|
+
label: "Secure connections to the site fail",
|
|
82948
|
+
severity: "major",
|
|
82949
|
+
detail: msg
|
|
82950
|
+
};
|
|
82951
|
+
}
|
|
82952
|
+
done({ ok: false, issue });
|
|
82953
|
+
});
|
|
82954
|
+
socket.on("timeout", () => {
|
|
82955
|
+
done({
|
|
82956
|
+
ok: false,
|
|
82957
|
+
issue: {
|
|
82958
|
+
code: "tls_timeout",
|
|
82959
|
+
label: "Secure connections to the site time out",
|
|
82960
|
+
severity: "major"
|
|
82961
|
+
}
|
|
82962
|
+
});
|
|
82963
|
+
});
|
|
82964
|
+
});
|
|
82965
|
+
}
|
|
82966
|
+
function markupIssues(html, finalUrl) {
|
|
82967
|
+
const issues = [];
|
|
82968
|
+
const head = html.slice(0, 6e4);
|
|
82969
|
+
if (!/<meta[^>]+name=["']?viewport/i.test(head)) {
|
|
82970
|
+
issues.push({
|
|
82971
|
+
code: "not_mobile",
|
|
82972
|
+
label: "Not built for phones \u2014 the page loads zoomed out and has to be pinched to read",
|
|
82973
|
+
severity: "critical"
|
|
82974
|
+
});
|
|
82975
|
+
}
|
|
82976
|
+
const tableCount = (html.match(/<table/gi) ?? []).length;
|
|
82977
|
+
if (tableCount >= 3 || /<table[^>]*>[\s\S]{0,400}<table/i.test(html)) {
|
|
82978
|
+
issues.push({
|
|
82979
|
+
code: "table_layout",
|
|
82980
|
+
label: "Built with table layouts, a technique abandoned around 2010",
|
|
82981
|
+
severity: "major",
|
|
82982
|
+
detail: `${tableCount} tables`
|
|
82983
|
+
});
|
|
82984
|
+
}
|
|
82985
|
+
if (/<(font|center|marquee|blink)\b/i.test(html)) {
|
|
82986
|
+
const tag = html.match(/<(font|center|marquee|blink)\b/i)?.[1]?.toLowerCase();
|
|
82987
|
+
issues.push({
|
|
82988
|
+
code: "obsolete_tags",
|
|
82989
|
+
label: `Uses HTML tags (<${tag}>) that have been removed from the web standard`,
|
|
82990
|
+
severity: "major"
|
|
82991
|
+
});
|
|
82992
|
+
}
|
|
82993
|
+
if (/\.swf\b|application\/x-shockwave-flash|<embed[^>]+flash/i.test(html)) {
|
|
82994
|
+
issues.push({
|
|
82995
|
+
code: "flash",
|
|
82996
|
+
label: "Contains Flash content, which no browser has supported since 2020 \u2014 that part of the site is invisible",
|
|
82997
|
+
severity: "critical"
|
|
82998
|
+
});
|
|
82999
|
+
}
|
|
83000
|
+
const jq = html.match(/jquery[.-]?(\d+)\.(\d+)(?:\.(\d+))?(?:\.min)?\.js/i);
|
|
83001
|
+
if (jq) {
|
|
83002
|
+
const major = Number(jq[1]);
|
|
83003
|
+
if (major < 3) {
|
|
83004
|
+
issues.push({
|
|
83005
|
+
code: "old_jquery",
|
|
83006
|
+
label: "Runs a jQuery version with publicly known security vulnerabilities",
|
|
83007
|
+
severity: "major",
|
|
83008
|
+
detail: `jQuery ${jq[1]}.${jq[2]}${jq[3] ? "." + jq[3] : ""}`
|
|
83009
|
+
});
|
|
83010
|
+
}
|
|
83011
|
+
}
|
|
83012
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
83013
|
+
const years = [...html.matchAll(/(?:©|©|copyright)\s*(?:\d{4}\s*[-–]\s*)?(\d{4})/gi)].map((m) => Number(m[1])).filter((y) => y >= 1995 && y <= year);
|
|
83014
|
+
if (years.length) {
|
|
83015
|
+
const newest = Math.max(...years);
|
|
83016
|
+
const age = year - newest;
|
|
83017
|
+
if (age >= 2) {
|
|
83018
|
+
issues.push({
|
|
83019
|
+
code: "stale_copyright",
|
|
83020
|
+
label: `The footer still says ${newest} \u2014 visitors read that as \u201Cpossibly closed down\u201D`,
|
|
83021
|
+
severity: age >= 5 ? "major" : "minor",
|
|
83022
|
+
detail: `${age} years out of date`
|
|
83023
|
+
});
|
|
83024
|
+
}
|
|
83025
|
+
}
|
|
83026
|
+
if (/under construction|coming soon|website is being (?:built|updated)/i.test(head)) {
|
|
83027
|
+
issues.push({
|
|
83028
|
+
code: "under_construction",
|
|
83029
|
+
label: "Still shows an \u201Cunder construction\u201D placeholder",
|
|
83030
|
+
severity: "critical"
|
|
83031
|
+
});
|
|
83032
|
+
}
|
|
83033
|
+
if (/this domain (?:is|may be) for sale|parked (?:free )?(?:at|by)|godaddy\.com\/domainfind|default web page/i.test(head)) {
|
|
83034
|
+
issues.push({
|
|
83035
|
+
code: "parked",
|
|
83036
|
+
label: "The domain shows a parking page rather than a real website",
|
|
83037
|
+
severity: "critical"
|
|
83038
|
+
});
|
|
83039
|
+
}
|
|
83040
|
+
if (finalUrl.startsWith("https://")) {
|
|
83041
|
+
const insecure = html.match(/(?:src|href)=["']http:\/\/(?!localhost)/gi);
|
|
83042
|
+
if (insecure && insecure.length >= 2) {
|
|
83043
|
+
issues.push({
|
|
83044
|
+
code: "mixed_content",
|
|
83045
|
+
label: "Loads images or scripts insecurely, so browsers block them and parts of the page break",
|
|
83046
|
+
severity: "major",
|
|
83047
|
+
detail: `${insecure.length} insecure references`
|
|
83048
|
+
});
|
|
83049
|
+
}
|
|
83050
|
+
}
|
|
83051
|
+
const title = html.match(/<title[^>]*>([\s\S]{0,200}?)<\/title>/i)?.[1]?.trim();
|
|
83052
|
+
if (!title) {
|
|
83053
|
+
issues.push({
|
|
83054
|
+
code: "no_title",
|
|
83055
|
+
label: "The page has no title, so search results show a bare web address",
|
|
83056
|
+
severity: "major"
|
|
83057
|
+
});
|
|
83058
|
+
} else if (/^(home|untitled|new page \d*|document|index|website)$/i.test(title)) {
|
|
83059
|
+
issues.push({
|
|
83060
|
+
code: "default_title",
|
|
83061
|
+
label: `The page title is still the template default (\u201C${title}\u201D), which is what Google shows`,
|
|
83062
|
+
severity: "major"
|
|
83063
|
+
});
|
|
83064
|
+
}
|
|
83065
|
+
if (!/<meta[^>]+name=["']?description/i.test(head)) {
|
|
83066
|
+
issues.push({
|
|
83067
|
+
code: "no_description",
|
|
83068
|
+
label: "No search description, so Google invents one from whatever text it finds",
|
|
83069
|
+
severity: "minor"
|
|
83070
|
+
});
|
|
83071
|
+
}
|
|
83072
|
+
if (/<frameset|<frame\b/i.test(html)) {
|
|
83073
|
+
issues.push({
|
|
83074
|
+
code: "frames",
|
|
83075
|
+
label: "Built with frames, which search engines cannot index properly",
|
|
83076
|
+
severity: "critical"
|
|
83077
|
+
});
|
|
83078
|
+
}
|
|
83079
|
+
return issues;
|
|
83080
|
+
}
|
|
83081
|
+
async function auditSite(websiteUrl) {
|
|
83082
|
+
const raw = (websiteUrl ?? "").trim();
|
|
83083
|
+
if (!raw)
|
|
83084
|
+
return { url: raw, reachable: false, score: 0, issues: [], error: "No URL" };
|
|
83085
|
+
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
83086
|
+
let hostname;
|
|
83087
|
+
try {
|
|
83088
|
+
hostname = new url_1.URL(withScheme).hostname;
|
|
83089
|
+
} catch {
|
|
83090
|
+
return { url: raw, reachable: false, score: 0, issues: [], error: "Unparseable URL" };
|
|
83091
|
+
}
|
|
83092
|
+
const issues = [];
|
|
83093
|
+
const listedAsHttp = /^http:\/\//i.test(raw);
|
|
83094
|
+
const [tlsResult, page] = await Promise.all([
|
|
83095
|
+
checkTls(hostname).catch(() => ({ ok: true })),
|
|
83096
|
+
fetchPage(withScheme)
|
|
83097
|
+
]);
|
|
83098
|
+
if (tlsResult.issue)
|
|
83099
|
+
issues.push(tlsResult.issue);
|
|
83100
|
+
if (listedAsHttp && !tlsResult.issue) {
|
|
83101
|
+
issues.push({
|
|
83102
|
+
code: "listed_insecure",
|
|
83103
|
+
label: "The address the business gives out starts with http:// \u2014 visitors following it get a \u201CNot secure\u201D warning",
|
|
83104
|
+
severity: "major"
|
|
83105
|
+
});
|
|
83106
|
+
}
|
|
83107
|
+
if (!page.ok) {
|
|
83108
|
+
return {
|
|
83109
|
+
url: raw,
|
|
83110
|
+
reachable: false,
|
|
83111
|
+
score: scoreOf(issues),
|
|
83112
|
+
issues,
|
|
83113
|
+
error: page.error
|
|
83114
|
+
};
|
|
83115
|
+
}
|
|
83116
|
+
if (page.finalUrl.startsWith("http://") && !tlsResult.issue) {
|
|
83117
|
+
issues.push({
|
|
83118
|
+
code: "no_https_redirect",
|
|
83119
|
+
label: "The site falls back to an unencrypted connection instead of staying secure",
|
|
83120
|
+
severity: "major"
|
|
83121
|
+
});
|
|
83122
|
+
}
|
|
83123
|
+
issues.push(...markupIssues(page.html, page.finalUrl));
|
|
83124
|
+
const htmlKb = Math.round(page.html.length / 1024);
|
|
83125
|
+
if (htmlKb > 600) {
|
|
83126
|
+
issues.push({
|
|
83127
|
+
code: "heavy_page",
|
|
83128
|
+
label: "The page is unusually heavy, so it loads slowly on mobile data",
|
|
83129
|
+
severity: "minor",
|
|
83130
|
+
detail: `${htmlKb} KB of HTML`
|
|
83131
|
+
});
|
|
83132
|
+
}
|
|
83133
|
+
return { url: raw, reachable: true, score: scoreOf(issues), issues, htmlKb };
|
|
83134
|
+
}
|
|
83135
|
+
async function fetchPage(url) {
|
|
83136
|
+
try {
|
|
83137
|
+
const res = await axios_1.default.get(url, {
|
|
83138
|
+
timeout: 2e4,
|
|
83139
|
+
maxRedirects: 5,
|
|
83140
|
+
responseType: "text",
|
|
83141
|
+
// Some hosts serve a stripped page to a bare client.
|
|
83142
|
+
headers: {
|
|
83143
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
|
83144
|
+
Accept: "text/html,application/xhtml+xml"
|
|
83145
|
+
},
|
|
83146
|
+
maxContentLength: 5e6,
|
|
83147
|
+
validateStatus: () => true,
|
|
83148
|
+
// We want to inspect a broken-cert site's markup too; the cert problem is
|
|
83149
|
+
// reported separately by checkTls, so this doesn't hide anything.
|
|
83150
|
+
httpsAgent: void 0
|
|
83151
|
+
});
|
|
83152
|
+
if (res.status >= 400) {
|
|
83153
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
83154
|
+
}
|
|
83155
|
+
const html = typeof res.data === "string" ? res.data : String(res.data ?? "");
|
|
83156
|
+
if (!html)
|
|
83157
|
+
return { ok: false, error: "Empty response" };
|
|
83158
|
+
const finalUrl = res.request?.res?.responseUrl || url;
|
|
83159
|
+
return { ok: true, html, finalUrl };
|
|
83160
|
+
} catch (e) {
|
|
83161
|
+
return { ok: false, error: e.message };
|
|
83162
|
+
}
|
|
83163
|
+
}
|
|
83164
|
+
function scoreOf(issues) {
|
|
83165
|
+
const total = issues.reduce((sum, i) => sum + WEIGHT[i.severity], 0);
|
|
83166
|
+
return Math.min(100, total);
|
|
83167
|
+
}
|
|
83168
|
+
function isRebuildProspect(audit) {
|
|
83169
|
+
if (!audit.issues.length)
|
|
83170
|
+
return false;
|
|
83171
|
+
const critical = audit.issues.filter((i) => i.severity === "critical").length;
|
|
83172
|
+
const major = audit.issues.filter((i) => i.severity === "major").length;
|
|
83173
|
+
return critical >= 1 || major >= 2;
|
|
83174
|
+
}
|
|
83175
|
+
function headlineIssues(audit, limit = 3) {
|
|
83176
|
+
const rank = { critical: 0, major: 1, minor: 2 };
|
|
83177
|
+
return [...audit.issues].sort((a, b) => rank[a.severity] - rank[b.severity]).slice(0, limit);
|
|
83178
|
+
}
|
|
83179
|
+
}
|
|
83180
|
+
});
|
|
83181
|
+
|
|
82543
83182
|
// ../desktop/dist/main/lib/lead-search.js
|
|
82544
83183
|
var require_lead_search = __commonJS({
|
|
82545
83184
|
"../desktop/dist/main/lib/lead-search.js"(exports2) {
|
|
@@ -82550,11 +83189,14 @@ var require_lead_search = __commonJS({
|
|
|
82550
83189
|
var db_1 = require_db();
|
|
82551
83190
|
var places_1 = require_places();
|
|
82552
83191
|
var booking_detect_1 = require_booking_detect();
|
|
83192
|
+
var site_audit_1 = require_site_audit();
|
|
82553
83193
|
async function searchAndSaveLeadsStream(args, onLead) {
|
|
82554
83194
|
const db2 = (0, db_1.getDb)();
|
|
82555
83195
|
const cap = args.maxResults ?? 50;
|
|
82556
83196
|
const seen = /* @__PURE__ */ new Set();
|
|
82557
83197
|
const bookingMode = args.mode === "no_booking";
|
|
83198
|
+
const auditMode = args.mode === "outdated";
|
|
83199
|
+
const wantsSiteHavers = bookingMode || auditMode;
|
|
82558
83200
|
let saved = 0;
|
|
82559
83201
|
let found = 0;
|
|
82560
83202
|
await (0, places_1.searchPlacesStream)({
|
|
@@ -82563,8 +83205,8 @@ var require_lead_search = __commonJS({
|
|
|
82563
83205
|
types: args.types,
|
|
82564
83206
|
center: args.center,
|
|
82565
83207
|
radiusMeters: args.radiusMeters,
|
|
82566
|
-
// Booking
|
|
82567
|
-
includeWithWebsite:
|
|
83208
|
+
// Booking and audit modes need the businesses that DO have a website.
|
|
83209
|
+
includeWithWebsite: wantsSiteHavers
|
|
82568
83210
|
}, async (batch) => {
|
|
82569
83211
|
for (const r of batch) {
|
|
82570
83212
|
found++;
|
|
@@ -82579,7 +83221,19 @@ var require_lead_search = __commonJS({
|
|
|
82579
83221
|
let leadKind = "no_website";
|
|
82580
83222
|
let bookingStatus = null;
|
|
82581
83223
|
let bookingPlatform = null;
|
|
82582
|
-
|
|
83224
|
+
let siteScore = null;
|
|
83225
|
+
let siteIssues = null;
|
|
83226
|
+
if (auditMode) {
|
|
83227
|
+
if (!r.website)
|
|
83228
|
+
continue;
|
|
83229
|
+
args.onProgress?.(`Auditing ${r.name}'s website\u2026`);
|
|
83230
|
+
const audit = await (0, site_audit_1.auditSite)(r.website);
|
|
83231
|
+
if (!(0, site_audit_1.isRebuildProspect)(audit))
|
|
83232
|
+
continue;
|
|
83233
|
+
siteScore = audit.score;
|
|
83234
|
+
siteIssues = JSON.stringify(audit.issues);
|
|
83235
|
+
leadKind = "outdated";
|
|
83236
|
+
} else if (bookingMode) {
|
|
82583
83237
|
if (!r.website)
|
|
82584
83238
|
continue;
|
|
82585
83239
|
args.onProgress?.(`Checking ${r.name}'s booking setup\u2026`);
|
|
@@ -82593,8 +83247,8 @@ var require_lead_search = __commonJS({
|
|
|
82593
83247
|
continue;
|
|
82594
83248
|
}
|
|
82595
83249
|
db2.run(`INSERT INTO leads
|
|
82596
|
-
(place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform)
|
|
82597
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)`, [
|
|
83250
|
+
(place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform, site_score, site_issues, audited_at)
|
|
83251
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?, ?, ?, ?)`, [
|
|
82598
83252
|
r.place_id,
|
|
82599
83253
|
r.name,
|
|
82600
83254
|
r.type,
|
|
@@ -82611,7 +83265,10 @@ var require_lead_search = __commonJS({
|
|
|
82611
83265
|
r.business_status,
|
|
82612
83266
|
leadKind,
|
|
82613
83267
|
bookingStatus,
|
|
82614
|
-
bookingPlatform
|
|
83268
|
+
bookingPlatform,
|
|
83269
|
+
siteScore,
|
|
83270
|
+
siteIssues,
|
|
83271
|
+
siteScore === null ? null : (/* @__PURE__ */ new Date()).toISOString()
|
|
82615
83272
|
]);
|
|
82616
83273
|
const row = db2.get("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
|
|
82617
83274
|
saved++;
|
|
@@ -83148,8 +83805,33 @@ var require_site_stats = __commonJS({
|
|
|
83148
83805
|
exports2.fetchSiteStats = fetchSiteStats;
|
|
83149
83806
|
exports2.pctChange = pctChange;
|
|
83150
83807
|
var axios_1 = __importDefault(require_axios());
|
|
83151
|
-
async function fetchSiteStats(siteUrl, adminPassword) {
|
|
83808
|
+
async function fetchSiteStats(siteUrl, adminPassword, reportKey) {
|
|
83152
83809
|
const base = siteUrl.replace(/\/$/, "");
|
|
83810
|
+
if (reportKey) {
|
|
83811
|
+
try {
|
|
83812
|
+
const res = await axios_1.default.get(`${base}/api/stats`, {
|
|
83813
|
+
timeout: 2e4,
|
|
83814
|
+
headers: { Authorization: `Bearer ${reportKey}` },
|
|
83815
|
+
validateStatus: () => true
|
|
83816
|
+
});
|
|
83817
|
+
if (res.status === 200)
|
|
83818
|
+
return { ok: true, data: res.data };
|
|
83819
|
+
if (res.status === 404) {
|
|
83820
|
+
return {
|
|
83821
|
+
ok: false,
|
|
83822
|
+
error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
|
|
83823
|
+
};
|
|
83824
|
+
}
|
|
83825
|
+
} catch (e) {
|
|
83826
|
+
return { ok: false, error: `Couldn't reach the site: ${e.message}` };
|
|
83827
|
+
}
|
|
83828
|
+
}
|
|
83829
|
+
if (!adminPassword) {
|
|
83830
|
+
return {
|
|
83831
|
+
ok: false,
|
|
83832
|
+
error: "No report key on this site and no stored admin password. Rebuild it (`sproutkit regenerate`) to provision a read-only report key."
|
|
83833
|
+
};
|
|
83834
|
+
}
|
|
83153
83835
|
let cookie;
|
|
83154
83836
|
try {
|
|
83155
83837
|
const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
|
|
@@ -94983,6 +95665,7 @@ var require_chat_tools = __commonJS({
|
|
|
94983
95665
|
var lead_search_1 = require_lead_search();
|
|
94984
95666
|
var places_1 = require_places();
|
|
94985
95667
|
var booking_detect_1 = require_booking_detect();
|
|
95668
|
+
var site_audit_1 = require_site_audit();
|
|
94986
95669
|
var followup_1 = require_followup();
|
|
94987
95670
|
var invoicing_1 = require_invoicing();
|
|
94988
95671
|
var agency_1 = require_agency();
|
|
@@ -95174,6 +95857,44 @@ var require_chat_tools = __commonJS({
|
|
|
95174
95857
|
required: ["id"]
|
|
95175
95858
|
}
|
|
95176
95859
|
},
|
|
95860
|
+
{
|
|
95861
|
+
name: "audit_sites",
|
|
95862
|
+
description: "AUDIT existing websites in an area and save the bad ones as rebuild prospects. Unlike find_leads (which looks for businesses with NO website), this finds businesses that DO have a website and checks it for real, nameable faults: expired or missing HTTPS certificates, no mobile support, table layouts, Flash, obsolete jQuery, parked or under-construction pages, stale copyright years. Each prospect is scored (higher = worse) and its specific problems are stored so outreach can cite them. Use this when the user wants to find outdated, insecure or badly built websites to pitch a rebuild against. Costs the same Places lookup as find_leads plus one fetch per site.",
|
|
95863
|
+
input_schema: {
|
|
95864
|
+
type: "object",
|
|
95865
|
+
properties: {
|
|
95866
|
+
location: {
|
|
95867
|
+
type: "string",
|
|
95868
|
+
description: "Town, postcode, area or address to search, e.g. 'Leeds LS1' or 'Shoreditch, London'."
|
|
95869
|
+
},
|
|
95870
|
+
categories: {
|
|
95871
|
+
type: "array",
|
|
95872
|
+
items: { type: "string" },
|
|
95873
|
+
description: "Business types to search, e.g. ['barber shop','cafe','plumber']."
|
|
95874
|
+
},
|
|
95875
|
+
radiusMeters: {
|
|
95876
|
+
type: "number",
|
|
95877
|
+
description: "Search radius in metres. Defaults to 10000."
|
|
95878
|
+
},
|
|
95879
|
+
limit: {
|
|
95880
|
+
type: "number",
|
|
95881
|
+
description: "Maximum prospects to save. Defaults to 25."
|
|
95882
|
+
}
|
|
95883
|
+
},
|
|
95884
|
+
required: ["location", "categories"]
|
|
95885
|
+
}
|
|
95886
|
+
},
|
|
95887
|
+
{
|
|
95888
|
+
name: "audit_one_site",
|
|
95889
|
+
description: "Audit a SINGLE website by URL and report its faults, without saving anything. Use this when the user names a specific site and asks what's wrong with it, or to check a business's site before pitching them.",
|
|
95890
|
+
input_schema: {
|
|
95891
|
+
type: "object",
|
|
95892
|
+
properties: {
|
|
95893
|
+
url: { type: "string", description: "The website to audit, e.g. 'thebarbers.co.uk'." },
|
|
95894
|
+
id: { type: "number", description: "Or a lead id \u2014 audits that lead's website and stores the result." }
|
|
95895
|
+
}
|
|
95896
|
+
}
|
|
95897
|
+
},
|
|
95177
95898
|
{
|
|
95178
95899
|
name: "hand_over_site",
|
|
95179
95900
|
description: "HAND OVER a finished site to the paying client: removes the DEMO/preview banner, optionally attaches the client's own domain, and returns every credential in one place (site URL, /admin panel + password, booking page + its password, and the DNS records the client must set at their registrar). This is the paid-deliverable step \u2014 the banner exists to stop clients using a free demo as their real site, so only run this when the user says the client has paid / signed off / is going live. Requires the lead to already have a built site or booking page.",
|
|
@@ -95946,24 +96667,97 @@ Interested?`;
|
|
|
95946
96667
|
}
|
|
95947
96668
|
case "site_report": {
|
|
95948
96669
|
const id = Number(input.id);
|
|
95949
|
-
const lead = db2.get(
|
|
96670
|
+
const lead = db2.get(`SELECT id, name, site_url, booking_url, admin_url, admin_password,
|
|
96671
|
+
booking_admin_password, report_key
|
|
96672
|
+
FROM leads WHERE id = ?`, [id]);
|
|
95950
96673
|
if (!lead)
|
|
95951
96674
|
throw new Error(`Lead #${id} not found`);
|
|
95952
96675
|
const base = lead.site_url || lead.booking_url;
|
|
95953
96676
|
if (!base)
|
|
95954
96677
|
return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
|
|
95955
96678
|
const password = lead.admin_password || lead.booking_admin_password;
|
|
95956
|
-
if (!password) {
|
|
96679
|
+
if (!password && !lead.report_key) {
|
|
95957
96680
|
return {
|
|
95958
96681
|
ok: false,
|
|
95959
|
-
error: `No admin password stored for #${id}, so I can't read its stats.
|
|
96682
|
+
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
96683
|
};
|
|
95961
96684
|
}
|
|
95962
|
-
const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
|
|
96685
|
+
const stats = await (0, site_stats_1.fetchSiteStats)(base, password, lead.report_key);
|
|
95963
96686
|
if (!stats.ok)
|
|
95964
96687
|
return { ok: false, error: stats.error, business: lead.name };
|
|
95965
96688
|
return { ok: true, id, business: lead.name, url: base, ...stats.data };
|
|
95966
96689
|
}
|
|
96690
|
+
case "audit_sites": {
|
|
96691
|
+
const location = String(input.location ?? "").trim();
|
|
96692
|
+
const categories = input.categories ?? [];
|
|
96693
|
+
if (!location || categories.length === 0) {
|
|
96694
|
+
return { ok: false, error: "Give me a location and at least one business category." };
|
|
96695
|
+
}
|
|
96696
|
+
const placesKey = getDecryptedSetting("places_key");
|
|
96697
|
+
if (!placesKey)
|
|
96698
|
+
throw new Error("Google Places API key not set in Settings");
|
|
96699
|
+
ctx.emitProgress?.({
|
|
96700
|
+
channel: "leads:searching",
|
|
96701
|
+
payload: { msg: `Auditing websites in ${location}\u2026` }
|
|
96702
|
+
});
|
|
96703
|
+
const matches = await (0, places_1.geocodeForward)(location, 1);
|
|
96704
|
+
const place = matches[0];
|
|
96705
|
+
const result = await (0, lead_search_1.searchAndSaveLeadsStream)({
|
|
96706
|
+
placesKey,
|
|
96707
|
+
city: location,
|
|
96708
|
+
types: categories,
|
|
96709
|
+
maxResults: Math.min(Number(input.limit ?? 25), 100),
|
|
96710
|
+
center: place ? { lat: place.lat, lng: place.lng } : void 0,
|
|
96711
|
+
radiusMeters: Number(input.radiusMeters ?? 1e4),
|
|
96712
|
+
mode: "outdated"
|
|
96713
|
+
}, (lead) => ctx.emitProgress?.({ channel: "leads:found", payload: { lead } }));
|
|
96714
|
+
ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "audit_sites" } });
|
|
96715
|
+
const saved = db2.all(`SELECT id, name, website, site_score, site_issues
|
|
96716
|
+
FROM leads
|
|
96717
|
+
WHERE lead_kind = 'outdated' AND audited_at IS NOT NULL
|
|
96718
|
+
ORDER BY audited_at DESC, site_score DESC
|
|
96719
|
+
LIMIT ?`, [Number(input.limit ?? 25)]);
|
|
96720
|
+
return {
|
|
96721
|
+
ok: true,
|
|
96722
|
+
found: result.found,
|
|
96723
|
+
saved: result.saved,
|
|
96724
|
+
prospects: saved.map((r) => ({
|
|
96725
|
+
id: r.id,
|
|
96726
|
+
business: r.name,
|
|
96727
|
+
website: r.website,
|
|
96728
|
+
score: r.site_score,
|
|
96729
|
+
issues: JSON.parse(r.site_issues || "[]").slice(0, 3).map((i) => `${i.severity}: ${i.label}`)
|
|
96730
|
+
}))
|
|
96731
|
+
};
|
|
96732
|
+
}
|
|
96733
|
+
case "audit_one_site": {
|
|
96734
|
+
let url = input.url ?? "";
|
|
96735
|
+
let leadId = null;
|
|
96736
|
+
if (input.id != null) {
|
|
96737
|
+
leadId = Number(input.id);
|
|
96738
|
+
const lead = db2.get("SELECT id, name, website FROM leads WHERE id = ?", [leadId]);
|
|
96739
|
+
if (!lead)
|
|
96740
|
+
throw new Error(`Lead #${leadId} not found`);
|
|
96741
|
+
if (!lead.website)
|
|
96742
|
+
return { ok: false, error: `#${leadId} ${lead.name} has no website on record.` };
|
|
96743
|
+
url = lead.website;
|
|
96744
|
+
}
|
|
96745
|
+
if (!url)
|
|
96746
|
+
return { ok: false, error: "Give me a website URL or a lead id." };
|
|
96747
|
+
const audit = await (0, site_audit_1.auditSite)(url);
|
|
96748
|
+
if (leadId !== null) {
|
|
96749
|
+
db2.run("UPDATE leads SET site_score = ?, site_issues = ?, audited_at = ? WHERE id = ?", [audit.score, JSON.stringify(audit.issues), (/* @__PURE__ */ new Date()).toISOString(), leadId]);
|
|
96750
|
+
}
|
|
96751
|
+
return {
|
|
96752
|
+
ok: true,
|
|
96753
|
+
url: audit.url,
|
|
96754
|
+
reachable: audit.reachable,
|
|
96755
|
+
score: audit.score,
|
|
96756
|
+
worthRebuilding: (0, site_audit_1.isRebuildProspect)(audit),
|
|
96757
|
+
issues: audit.issues,
|
|
96758
|
+
error: audit.error
|
|
96759
|
+
};
|
|
96760
|
+
}
|
|
95967
96761
|
case "hand_over_site": {
|
|
95968
96762
|
const id = Number(input.id);
|
|
95969
96763
|
const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
|
|
@@ -96997,7 +97791,7 @@ var require_package2 = __commonJS({
|
|
|
96997
97791
|
"package.json"(exports2, module2) {
|
|
96998
97792
|
module2.exports = {
|
|
96999
97793
|
name: "sproutkit-code",
|
|
97000
|
-
version: "1.
|
|
97794
|
+
version: "1.8.0",
|
|
97001
97795
|
description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
|
|
97002
97796
|
bin: {
|
|
97003
97797
|
sproutkit: "./dist/index.js"
|
|
@@ -98869,6 +99663,135 @@ async function cmdReport(argv) {
|
|
|
98869
99663
|
process.exitCode = 1;
|
|
98870
99664
|
}
|
|
98871
99665
|
}
|
|
99666
|
+
async function cmdAudit(argv) {
|
|
99667
|
+
const { flags, rest } = parseArgs(argv);
|
|
99668
|
+
const json = !!flags.json;
|
|
99669
|
+
const SEV_COLOUR = {
|
|
99670
|
+
critical: c.red,
|
|
99671
|
+
major: c.amber,
|
|
99672
|
+
minor: c.dim
|
|
99673
|
+
};
|
|
99674
|
+
const asId = Number(flags.id ?? rest[0]);
|
|
99675
|
+
const singleUrl = flags.url ? String(flags.url) : void 0;
|
|
99676
|
+
const looksLikeUrl = singleUrl || rest[0] && /\./.test(String(rest[0])) && !flags.categories;
|
|
99677
|
+
if (singleUrl || !Number.isNaN(asId) && rest.length === 1 && !flags.categories || looksLikeUrl) {
|
|
99678
|
+
const status2 = json ? void 0 : new Status();
|
|
99679
|
+
const target = singleUrl ?? (Number.isNaN(asId) ? String(rest[0]) : `lead #${asId}`);
|
|
99680
|
+
if (status2) status2.start(`Auditing ${target}`);
|
|
99681
|
+
try {
|
|
99682
|
+
const r = await runTool(
|
|
99683
|
+
"audit_one_site",
|
|
99684
|
+
Number.isNaN(asId) || singleUrl ? { url: singleUrl ?? String(rest[0]) } : { id: asId },
|
|
99685
|
+
makeCtx({ json, status: status2 })
|
|
99686
|
+
);
|
|
99687
|
+
if (r && r.ok === false) {
|
|
99688
|
+
if (status2) status2.done(false, r.error ?? "Couldn't audit it");
|
|
99689
|
+
if (json) emitJson(r);
|
|
99690
|
+
process.exitCode = 1;
|
|
99691
|
+
return;
|
|
99692
|
+
}
|
|
99693
|
+
if (status2) {
|
|
99694
|
+
status2.done(
|
|
99695
|
+
true,
|
|
99696
|
+
r.issues && r.issues.length ? `${r.issues.length} issue${r.issues.length === 1 ? "" : "s"} \xB7 score ${r.score}` : "No problems found"
|
|
99697
|
+
);
|
|
99698
|
+
}
|
|
99699
|
+
if (json) {
|
|
99700
|
+
emitJson(r);
|
|
99701
|
+
return;
|
|
99702
|
+
}
|
|
99703
|
+
say();
|
|
99704
|
+
say(` ${c.bold(String(r.url))}` + c.dim(` \xB7 score ${r.score}/100`));
|
|
99705
|
+
if (!r.issues?.length) {
|
|
99706
|
+
say(c.dim(" Nothing worth pitching against \u2014 this site is in decent shape."));
|
|
99707
|
+
say();
|
|
99708
|
+
return;
|
|
99709
|
+
}
|
|
99710
|
+
say();
|
|
99711
|
+
for (const i of r.issues) {
|
|
99712
|
+
const tint = SEV_COLOUR[i.severity] ?? c.dim;
|
|
99713
|
+
say(` ${tint("\u25CF")} ${i.label}${i.detail ? c.dim(` (${i.detail})`) : ""}`);
|
|
99714
|
+
}
|
|
99715
|
+
say();
|
|
99716
|
+
say(
|
|
99717
|
+
r.worthRebuilding ? c.green(" Worth approaching \u2014 these are concrete problems you can name.") : c.dim(" Minor only. Probably not worth a rebuild pitch.")
|
|
99718
|
+
);
|
|
99719
|
+
say();
|
|
99720
|
+
} catch (e) {
|
|
99721
|
+
if (status2) status2.done(false, "Audit failed");
|
|
99722
|
+
json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
|
|
99723
|
+
process.exitCode = 1;
|
|
99724
|
+
}
|
|
99725
|
+
return;
|
|
99726
|
+
}
|
|
99727
|
+
const location = rest.join(" ").trim() || (flags.location ? String(flags.location) : "");
|
|
99728
|
+
if (!location) {
|
|
99729
|
+
const err = 'Usage: sproutkit audit "<area>" --categories "barber shop,cafe"\n single site: sproutkit audit <leadId> \xB7 sproutkit audit --url thebarbers.co.uk';
|
|
99730
|
+
json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
|
|
99731
|
+
process.exitCode = 1;
|
|
99732
|
+
return;
|
|
99733
|
+
}
|
|
99734
|
+
const categories = flags.categories ? String(flags.categories).split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
99735
|
+
if (categories.length === 0) {
|
|
99736
|
+
const err = 'Which trades? e.g. --categories "barber shop,cafe,plumber"';
|
|
99737
|
+
json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
|
|
99738
|
+
process.exitCode = 1;
|
|
99739
|
+
return;
|
|
99740
|
+
}
|
|
99741
|
+
const status = json ? void 0 : new Status();
|
|
99742
|
+
if (status) status.start(`Auditing websites in ${location}`);
|
|
99743
|
+
try {
|
|
99744
|
+
const r = await runTool(
|
|
99745
|
+
"audit_sites",
|
|
99746
|
+
{
|
|
99747
|
+
location,
|
|
99748
|
+
categories,
|
|
99749
|
+
radiusMeters: flags.radius ? Number(flags.radius) : void 0,
|
|
99750
|
+
limit: flags.limit ? Number(flags.limit) : void 0
|
|
99751
|
+
},
|
|
99752
|
+
makeCtx({ json, status })
|
|
99753
|
+
);
|
|
99754
|
+
if (r && r.ok === false) {
|
|
99755
|
+
if (status) status.done(false, r.error ?? "Audit failed");
|
|
99756
|
+
if (json) emitJson(r);
|
|
99757
|
+
process.exitCode = 1;
|
|
99758
|
+
return;
|
|
99759
|
+
}
|
|
99760
|
+
if (status) status.done(true, `${r.saved} rebuild prospect${r.saved === 1 ? "" : "s"} from ${r.found} checked`);
|
|
99761
|
+
autoSync();
|
|
99762
|
+
if (json) {
|
|
99763
|
+
emitJson(r);
|
|
99764
|
+
return;
|
|
99765
|
+
}
|
|
99766
|
+
const list = r.prospects ?? [];
|
|
99767
|
+
if (!list.length) {
|
|
99768
|
+
say();
|
|
99769
|
+
say(c.dim(` Checked ${r.found} businesses \u2014 none had a site bad enough to pitch against.`));
|
|
99770
|
+
say(c.dim(" That's a good sign for the area; try a different trade or a wider radius."));
|
|
99771
|
+
say();
|
|
99772
|
+
return;
|
|
99773
|
+
}
|
|
99774
|
+
say();
|
|
99775
|
+
say(c.dim(" Worst first \u2014 these are the easiest conversations to start."));
|
|
99776
|
+
say();
|
|
99777
|
+
for (const p of list) {
|
|
99778
|
+
say(` ${c.greenBold("#" + p.id)} ${c.bold(p.business)} ${c.dim(p.website.replace(/^https?:\/\//, ""))}`);
|
|
99779
|
+
say(` ${c.amber("score " + p.score + "/100")}`);
|
|
99780
|
+
for (const issue of p.issues) {
|
|
99781
|
+
const [sev, ...restText] = issue.split(": ");
|
|
99782
|
+
const tint = SEV_COLOUR[sev] ?? c.dim;
|
|
99783
|
+
say(` ${tint("\u25CF")} ${restText.join(": ")}`);
|
|
99784
|
+
}
|
|
99785
|
+
say();
|
|
99786
|
+
}
|
|
99787
|
+
say(c.dim(" Build one: sproutkit build <id> \xB7 see everything: sproutkit audit <id>"));
|
|
99788
|
+
say();
|
|
99789
|
+
} catch (e) {
|
|
99790
|
+
if (status) status.done(false, "Audit failed");
|
|
99791
|
+
json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
|
|
99792
|
+
process.exitCode = 1;
|
|
99793
|
+
}
|
|
99794
|
+
}
|
|
98872
99795
|
async function cmdRegenerate(argv) {
|
|
98873
99796
|
const { flags, rest } = parseArgs(argv);
|
|
98874
99797
|
const json = !!flags.json;
|
|
@@ -99131,6 +100054,8 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
|
|
|
99131
100054
|
COMMANDS:
|
|
99132
100055
|
- sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
|
|
99133
100056
|
- sproutkit find "<area>" --categories "\u2026" --booking --json \u2014 find businesses that HAVE a site but take bookings via WhatsApp/phone only (booking-page prospects)
|
|
100057
|
+
- sproutkit audit "<area>" --categories "\u2026" --json \u2014 find businesses whose EXISTING site is insecure/outdated
|
|
100058
|
+
- sproutkit audit <id> | --url <site> --json \u2014 audit one site, list its faults
|
|
99134
100059
|
- sproutkit list [--status <s>] [--city <c>] [--limit 100] \u2014 formatted table; SHOW IT VERBATIM
|
|
99135
100060
|
- sproutkit list --json \u2014 same data as JSON, for reading ids only
|
|
99136
100061
|
- sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
|
|
@@ -99330,7 +100255,7 @@ async function main() {
|
|
|
99330
100255
|
closeTty();
|
|
99331
100256
|
return;
|
|
99332
100257
|
}
|
|
99333
|
-
if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
|
|
100258
|
+
if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "audit" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
|
|
99334
100259
|
const subArgs = process.argv.slice(3);
|
|
99335
100260
|
if (subArgs.includes("--help") || subArgs.includes("-h")) {
|
|
99336
100261
|
const USAGE = {
|
|
@@ -99370,6 +100295,16 @@ async function main() {
|
|
|
99370
100295
|
' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
|
|
99371
100296
|
" --email <addr> owner email for booking notifications"
|
|
99372
100297
|
],
|
|
100298
|
+
audit: [
|
|
100299
|
+
'sproutkit audit "<area>" --categories "barber shop,cafe"',
|
|
100300
|
+
" finds businesses that HAVE a website but whose site is costing them",
|
|
100301
|
+
" work \u2014 no HTTPS, unusable on phones, built in 2008 \u2014 and scores it",
|
|
100302
|
+
" --radius <m> search radius (default 10000)",
|
|
100303
|
+
" --limit <n> max prospects to save (default 25)",
|
|
100304
|
+
"",
|
|
100305
|
+
" one site: sproutkit audit <leadId>",
|
|
100306
|
+
" sproutkit audit --url thebarbers.co.uk"
|
|
100307
|
+
],
|
|
99373
100308
|
brand: [
|
|
99374
100309
|
'sproutkit brand --name "Your Studio" --email you@x.com [--vat-rate 20]',
|
|
99375
100310
|
" your agency identity \u2014 goes on invoices, letters, the site footer",
|
|
@@ -99447,6 +100382,7 @@ async function main() {
|
|
|
99447
100382
|
else if (arg === "cms") await cmdCms(subArgs);
|
|
99448
100383
|
else if (arg === "book") await cmdBook(subArgs);
|
|
99449
100384
|
else if (arg === "handover") await cmdHandover(subArgs);
|
|
100385
|
+
else if (arg === "audit") await cmdAudit(subArgs);
|
|
99450
100386
|
else if (arg === "brand") await cmdBrand(subArgs);
|
|
99451
100387
|
else if (arg === "invoice") await cmdInvoice(subArgs);
|
|
99452
100388
|
else if (arg === "letter") await cmdLetter(subArgs);
|