refactor: improve maintainability by reducing LOC/reuse

This commit is contained in:
nate 2026-03-28 16:52:19 +04:00
parent 8831c9c7b4
commit 6dcb5c0a52
29 changed files with 311 additions and 468 deletions

View File

@ -1,4 +1,5 @@
import postgres from "postgres"; import postgres from "postgres";
import { migrate as sharedMigrate } from "../../shared/db";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
max: 20, max: 20,
@ -9,80 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local
export default sql; export default sql;
export async function migrate() { export async function migrate() {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`; await sharedMigrate(sql);
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request_headers JSONB,
request_body TEXT,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
)
`;
// Migrations for existing deployments
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS regions TEXT[] NOT NULL DEFAULT '{}'`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS region TEXT`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
// Response bodies stored separately to keep pings table lean
await sql`
CREATE TABLE IF NOT EXISTS ping_bodies (
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
body TEXT
)
`;
await sql`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
)
`;
console.log("DB ready");
} }

View File

@ -1,19 +1,12 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { ingest } from "./routes/pings"; import { ingest } from "./routes/pings";
import { monitors } from "./routes/monitors"; import { monitors } from "./routes/monitors";
import { account } from "./routes/auth"; import { account } from "./routes/auth";
import { internal } from "./routes/internal"; import { internal } from "./routes/internal";
import { migrate } from "./db"; import { migrate } from "./db";
await migrate(); import { SECURITY_HEADERS } from "../../shared/auth";
const SECURITY_HEADERS = { await migrate();
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
};
const elysia = new Elysia() const elysia = new Elysia()
.get("/", () => ({ .get("/", () => ({

View File

@ -1,33 +1,13 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { createHmac, randomBytes } from "crypto";
import sql from "../db"; import sql from "../db";
import { createRateLimiter } from "../utils/rate-limit"; import { createRateLimiter } from "../../../shared/rate-limit";
import { getPlanLimits } from "../utils/plans"; import { getPlanLimits } from "../../../shared/plans";
import { generateKey, hashEmail, resolveKey as sharedResolveKey, extractAuthKey, COOKIE_OPTS } from "../../../shared/auth";
// ── Per-IP rate limiting for auth endpoints ───────────────────────────
const checkAuthRateLimit = createRateLimiter(); const checkAuthRateLimit = createRateLimiter();
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key"; async function resolveKey(key: string) {
return sharedResolveKey(sql, key);
function generateKey(): string {
return randomBytes(32).toString("base64url");
}
function hashEmail(email: string): string {
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
}
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
}
return null;
} }
export { resolveKey }; export { resolveKey };
@ -35,11 +15,7 @@ export { resolveKey };
export function requireAuth(app: Elysia) { export function requireAuth(app: Elysia) {
return app return app
.derive(async ({ headers, cookie, set }) => { .derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? ""; const key = extractAuthKey(headers, cookie);
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) { if (!key) {
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string }; return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string };
@ -59,15 +35,6 @@ export function requireAuth(app: Elysia) {
}); });
} }
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== "false",
sameSite: "none" as const,
path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 30, // 30 days
};
export const account = new Elysia({ prefix: "/account" }) export const account = new Elysia({ prefix: "/account" })
.post("/login", async ({ body, cookie, set, request }) => { .post("/login", async ({ body, cookie, set, request }) => {

View File

@ -3,7 +3,7 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import sql from "../db"; import sql from "../db";
import { safeTokenCompare } from "../utils/token"; import { safeTokenCompare } from "../../../shared/auth";
export async function pruneOldPings(retentionDays = 90) { export async function pruneOldPings(retentionDays = 90) {
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`; const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;

View File

@ -2,7 +2,7 @@ import { Elysia, t } from "elysia";
import { requireAuth } from "./auth"; import { requireAuth } from "./auth";
import sql from "../db"; import sql from "../db";
import { validateMonitorUrl } from "../utils/ssrf"; import { validateMonitorUrl } from "../utils/ssrf";
import { getPlanLimits } from "../utils/plans"; import { getPlanLimits } from "../../../shared/plans";
const MonitorBody = t.Object({ const MonitorBody = t.Object({
name: t.String({ maxLength: 200, description: "Human-readable name" }), name: t.String({ maxLength: 200, description: "Human-readable name" }),

View File

@ -1,7 +1,7 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import sql from "../db"; import sql from "../db";
import { resolveKey } from "./auth"; import { resolveKey } from "./auth";
import { safeTokenCompare } from "../utils/token"; import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
// ── SSE bus ─────────────────────────────────────────────────────────────────── // ── SSE bus ───────────────────────────────────────────────────────────────────
type SSEController = ReadableStreamDefaultController<Uint8Array>; type SSEController = ReadableStreamDefaultController<Uint8Array>;
@ -121,9 +121,7 @@ export const ingest = new Elysia()
// Fetch response body for a specific ping // Fetch response body for a specific ping
.get("/pings/:id/body", async ({ params, headers, cookie, set }) => { .get("/pings/:id/body", async ({ params, headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? ""; const key = extractAuthKey(headers, cookie);
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const key = bearer ?? cookie?.pingql_key?.value;
if (!key) { set.status = 401; return { error: "Unauthorized" }; } if (!key) { set.status = 401; return { error: "Unauthorized" }; }
const resolved = await resolveKey(key); const resolved = await resolveKey(key);
@ -143,10 +141,7 @@ export const ingest = new Elysia()
// SSE: single stream for all of the account's monitors // SSE: single stream for all of the account's monitors
.get("/account/stream", async ({ headers, cookie }) => { .get("/account/stream", async ({ headers, cookie }) => {
const authHeader = headers["authorization"] ?? ""; const key = extractAuthKey(headers, cookie);
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const key = bearer ?? cookie?.pingql_key?.value;
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
const resolved = await resolveKey(key); const resolved = await resolveKey(key);

View File

@ -1,42 +0,0 @@
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime";
export interface PlanLimits {
maxMonitors: number;
minIntervalS: number;
maxRegions: number;
}
const PLANS: Record<Plan, PlanLimits> = {
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
};
export function getPlanLimits(plan: string): PlanLimits {
return PLANS[plan as Plan] || PLANS.free;
}
// Display helpers
export const PLAN_LABELS: Record<string, string> = {
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime",
};
export const PRO_MULTIPLIERS = [
{ plan: "pro", label: "1x", monitors: 200, interval: "5s", priceMultiplier: 1 },
{ plan: "pro2x", label: "2x", monitors: 400, interval: "5s", priceMultiplier: 2 },
{ plan: "pro4x", label: "4x", monitors: 800, interval: "5s", priceMultiplier: 4 },
];
export const PRO_MONTHLY_USD = 12;
export const LIFETIME_USD = 140;
// Tier ranking for plan stacking decisions
const PLAN_RANK: Record<string, number> = {
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3,
};
export function planTier(plan: string): number {
return PLAN_RANK[plan] ?? 0;
}

View File

@ -1,9 +0,0 @@
import { timingSafeEqual } from "crypto";
export function safeTokenCompare(a: string | undefined, b: string | undefined): boolean {
if (!a || !b) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

View File

@ -1,4 +1,5 @@
import postgres from "postgres"; import postgres from "postgres";
import { migrate as sharedMigrate } from "../../shared/db";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
max: 10, max: 10,
@ -9,53 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local
export default sql; export default sql;
export async function migrate() { export async function migrate() {
// Plan columns on accounts (may already exist from API/web migrations) await sharedMigrate(sql);
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
await sql`
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
months INTEGER,
amount_usd NUMERIC(10,2) NOT NULL,
coin TEXT NOT NULL,
amount_crypto TEXT NOT NULL,
address TEXT NOT NULL,
derivation_index INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
txid TEXT
)
`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`;
await sql`
CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
txid TEXT NOT NULL,
amount TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(payment_id, txid)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`;
// Derivation index should be unique per coin, not globally
await sql`DROP INDEX IF EXISTS payments_derivation_index_key`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
console.log("Pay DB ready");
} }

View File

@ -5,12 +5,7 @@ import { checkPayments, expireProPlans } from "./monitor";
await migrate(); await migrate();
const SECURITY_HEADERS = { import { SECURITY_HEADERS } from "../../shared/auth";
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin",
};
const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"]; const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"];

View File

@ -2,7 +2,7 @@
/// States: pending → underpaid → confirming → paid | expired /// States: pending → underpaid → confirming → paid | expired
import sql from "./db"; import sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans"; import { COINS, planTier } from "../../shared/plans";
import { generateReceipt } from "./receipt"; import { generateReceipt } from "./receipt";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
@ -226,9 +226,6 @@ interface StackEntry { plan: string; remaining_days: number | null }
interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] } interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
interface AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] } interface AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
const PLAN_RANK: Record<string, number> = { free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3 };
function planTier(plan: string): number { return PLAN_RANK[plan] ?? 0; }
export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] { export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] {
const result = stack.slice(); const result = stack.slice();
// Merge if same plan already exists // Merge if same plan already exists

View File

@ -1,19 +0,0 @@
// Pro pricing — base $12/mo, multiplied for 2x/4x
export const PRO_MONTHLY_USD = 12;
export const LIFETIME_USD = 140;
export const PLANS: Record<string, { label: string; monthlyUsd?: number; priceUsd?: number }> = {
pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD },
pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 },
pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 },
lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD },
};
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
btc: { label: "Bitcoin", ticker: "BTC", confirmations: 1, uri: "bitcoin" },
ltc: { label: "Litecoin", ticker: "LTC", confirmations: 1, uri: "litecoin" },
doge: { label: "Dogecoin", ticker: "DOGE", confirmations: 1, uri: "dogecoin" },
dash: { label: "Dash", ticker: "DASH", confirmations: 1, uri: "dash" },
bch: { label: "Bitcoin Cash", ticker: "BCH", confirmations: 0, uri: "bitcoincash" },
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
};

View File

@ -1,5 +1,5 @@
import sql from "./db"; import sql from "./db";
import { COINS } from "./plans"; import { COINS } from "../../shared/plans";
export async function generateReceipt(paymentId: number): Promise<string> { export async function generateReceipt(paymentId: number): Promise<string> {
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`; const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;

View File

@ -2,33 +2,20 @@ import { Elysia, t } from "elysia";
import sql from "./db"; import sql from "./db";
import { derive } from "./address"; import { derive } from "./address";
import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom"; import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom";
import { PLANS, COINS } from "./plans"; import { PLAN_PRICING as PLANS, COINS } from "../../shared/plans";
import { generateReceipt } from "./receipt"; import { generateReceipt } from "./receipt";
import { watchPayment } from "./monitor"; import { watchPayment } from "./monitor";
import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth";
// Resolve account from key (same logic as API/web apps)
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
return null;
}
function requireAuth(app: Elysia) { function requireAuth(app: Elysia) {
return app return app
.derive(async ({ headers, cookie, set }) => { .derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? ""; const key = extractAuthKey(headers, cookie);
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) { if (!key) {
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" }; return { accountId: null as string | null, keyId: null as string | null, plan: "free" };
} }
const resolved = await resolveKey(key); const resolved = await sharedResolveKey(sql, key, { trackUsage: false });
if (resolved) return resolved; if (resolved) return resolved;
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" }; return { accountId: null as string | null, keyId: null as string | null, plan: "free" };

59
apps/shared/auth.ts Normal file
View File

@ -0,0 +1,59 @@
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
export function generateKey(): string {
return randomBytes(32).toString("base64url");
}
export function hashEmail(email: string): string {
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
}
export function extractAuthKey(headers: Record<string, string | undefined>, cookie: any): string | null {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
return bearer ?? cookie?.pingql_key?.value ?? null;
}
export async function resolveKey(
sql: any, key: string, opts?: { trackUsage?: boolean }
): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) {
if (opts?.trackUsage !== false) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
}
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
}
return null;
}
export const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== "false",
sameSite: "none" as const,
path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 30,
};
export function safeTokenCompare(a: string | undefined, b: string | undefined): boolean {
if (!a || !b) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
export const SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
};

121
apps/shared/db.ts Normal file
View File

@ -0,0 +1,121 @@
export async function migrate(sql: any) {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
// ── Core tables ─────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request_headers JSONB,
request_body TEXT,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
)
`;
await sql`
CREATE TABLE IF NOT EXISTS ping_bodies (
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
body TEXT
)
`;
await sql`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
)
`;
// ── Column migrations ──────────────────────────────────────────
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS regions TEXT[] NOT NULL DEFAULT '{}'`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS region TEXT`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
// ── Indexes ────────────────────────────────────────────────────
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
// ── Payment tables ─────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
months INTEGER,
amount_usd NUMERIC(10,2) NOT NULL,
coin TEXT NOT NULL,
amount_crypto TEXT NOT NULL,
address TEXT NOT NULL,
derivation_index INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
txid TEXT
)
`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`;
await sql`
CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
txid TEXT NOT NULL,
amount TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(payment_id, txid)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
await sql`DROP INDEX IF EXISTS payments_derivation_index_key`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
console.log("DB ready");
}

78
apps/shared/plans.ts Normal file
View File

@ -0,0 +1,78 @@
// ── Types ─────────────────────────────────────────────────────────
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime";
export interface PlanLimits {
maxMonitors: number;
minIntervalS: number;
maxRegions: number;
}
// ── Limits ────────────────────────────────────────────────────────
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
};
export function getPlanLimits(plan: string): PlanLimits {
return PLAN_LIMITS[plan as Plan] || PLAN_LIMITS.free;
}
// ── Display ───────────────────────────────────────────────────────
export const PLAN_LABELS: Record<string, string> = {
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime",
};
export const PRO_MULTIPLIERS = [
{ plan: "pro", label: "1x", monitors: 200, interval: "5s", priceMultiplier: 1 },
{ plan: "pro2x", label: "2x", monitors: 400, interval: "5s", priceMultiplier: 2 },
{ plan: "pro4x", label: "4x", monitors: 800, interval: "5s", priceMultiplier: 4 },
];
// ── Pricing ───────────────────────────────────────────────────────
export const PRO_MONTHLY_USD = 12;
export const LIFETIME_USD = 140;
export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number; priceUsd?: number }> = {
pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD },
pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 },
pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 },
lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD },
};
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
btc: { label: "Bitcoin", ticker: "BTC", confirmations: 1, uri: "bitcoin" },
ltc: { label: "Litecoin", ticker: "LTC", confirmations: 1, uri: "litecoin" },
doge: { label: "Dogecoin", ticker: "DOGE", confirmations: 1, uri: "dogecoin" },
dash: { label: "Dash", ticker: "DASH", confirmations: 1, uri: "dash" },
bch: { label: "Bitcoin Cash", ticker: "BCH", confirmations: 0, uri: "bitcoincash" },
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
};
// ── Tier ranking (for plan stacking) ──────────────────────────────
const PLAN_RANK: Record<string, number> = {
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3,
};
export function planTier(plan: string): number {
return PLAN_RANK[plan] ?? 0;
}
// ── Regions ───────────────────────────────────────────────────────
export const REGION_COLORS: Record<string, string> = {
"eu-central": "#3b82f6",
"us-west": "#f59e0b",
"__none__": "#6b7280",
};
export const REGION_LABELS: Record<string, string> = {
"eu-central": "EU Central",
"us-west": "US West",
};
export const REGIONS: [string, string][] = [
["eu-central", "EU Central"],
["us-west", "US West"],
];

View File

@ -1,4 +1,5 @@
import postgres from "postgres"; import postgres from "postgres";
import { migrate as sharedMigrate } from "../../shared/db";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
max: 20, max: 20,
@ -9,77 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local
export default sql; export default sql;
export async function migrate() { export async function migrate() {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`; await sharedMigrate(sql);
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request_headers JSONB,
request_body TEXT,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
)
`;
// Migrations for existing deployments
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
// Response bodies stored separately to keep pings table lean
await sql`
CREATE TABLE IF NOT EXISTS ping_bodies (
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
body TEXT
)
`;
await sql`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
)
`;
console.log("DB ready");
} }

View File

@ -6,13 +6,7 @@ import { migrate } from "./db";
await migrate(); await migrate();
const SECURITY_HEADERS = { import { SECURITY_HEADERS } from "../../shared/auth";
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
};
const app = new Elysia() const app = new Elysia()
.onAfterHandle(({ set }) => { .onAfterHandle(({ set }) => {

View File

@ -1,9 +1,7 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { createHmac, randomBytes } from "crypto";
import sql from "../db"; import sql from "../db";
import { createRateLimiter } from "../utils/rate-limit"; import { createRateLimiter } from "../../../shared/rate-limit";
import { generateKey, hashEmail, resolveKey as sharedResolveKey, extractAuthKey, COOKIE_OPTS } from "../../../shared/auth";
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
function redir(to: string) { function redir(to: string) {
return new Response( return new Response(
@ -12,28 +10,10 @@ function redir(to: string) {
); );
} }
// ── Per-IP rate limiting for auth endpoints ───────────────────────────
const checkAuthRateLimit = createRateLimiter(); const checkAuthRateLimit = createRateLimiter();
function generateKey(): string { async function resolveKey(key: string) {
return randomBytes(32).toString("base64url"); return sharedResolveKey(sql, key);
}
function hashEmail(email: string): string {
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
}
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
}
return null;
} }
export { resolveKey }; export { resolveKey };
@ -41,11 +21,7 @@ export { resolveKey };
export function requireAuth(app: Elysia) { export function requireAuth(app: Elysia) {
return app return app
.derive(async ({ headers, cookie, set }) => { .derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? ""; const key = extractAuthKey(headers, cookie);
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) { if (!key) {
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null }; return { accountId: null as string | null, keyId: null as string | null };
@ -65,15 +41,6 @@ export function requireAuth(app: Elysia) {
}); });
} }
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== "false",
sameSite: "none" as const,
path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 30, // 30 days
};
export const account = new Elysia({ prefix: "/account" }) export const account = new Elysia({ prefix: "/account" })
.post("/login", async ({ body, cookie, set, request, error }) => { .post("/login", async ({ body, cookie, set, request, error }) => {

View File

@ -5,8 +5,8 @@ import { resolveKey } from "./auth";
import sql from "../db"; import sql from "../db";
import { sparkline, sparklineFromPings, pickBestRegion } from "../utils/sparkline"; import { sparkline, sparklineFromPings, pickBestRegion } from "../utils/sparkline";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS } from "../../../shared/plans";
// Generate a cache-buster hash from the CSS file content at startup
const cssFile = Bun.file(resolve(import.meta.dir, "../dashboard/tailwind.css")); const cssFile = Bun.file(resolve(import.meta.dir, "../dashboard/tailwind.css"));
const cssHash = createHash("md5").update(await cssFile.bytes()).digest("hex").slice(0, 8); const cssHash = createHash("md5").update(await cssFile.bytes()).digest("hex").slice(0, 8);
const jsFile = Bun.file(resolve(import.meta.dir, "../dashboard/app.js")); const jsFile = Bun.file(resolve(import.meta.dir, "../dashboard/app.js"));
@ -23,18 +23,6 @@ function timeAgoSSR(date: string | Date): string {
const sparklineSSR = sparklineFromPings; const sparklineSSR = sparklineFromPings;
const REGION_COLORS: Record<string, string> = {
'eu-central': '#3b82f6', // blue
'us-west': '#f59e0b', // amber
'__none__': '#6b7280', // gray for null region
};
const REGION_LABELS: Record<string, string> = {
'eu-central': '🇩🇪 EU',
'us-west': '🇺🇸 US-W',
'__none__': '?',
};
function latencyChartSSR(pings: any[]): string { function latencyChartSSR(pings: any[]): string {
const data = pings.filter((c: any) => c.latency_ms != null); const data = pings.filter((c: any) => c.latency_ms != null);
if (data.length < 2) { if (data.length < 2) {
@ -137,12 +125,6 @@ function escapeHtmlSSR(str: string): string {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
export function html(template: string, data: Record<string, unknown> = {}) {
return new Response(eta.render(template, { ...data, timeAgoSSR, sparklineSSR, pickBestRegion, latencyChartSSR, escapeHtmlSSR, cssHash, jsHash }), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
function redirect(to: string) { function redirect(to: string) {
return new Response( return new Response(
`<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta http-equiv="refresh" content="0;url=${to}"><meta name="robots" content="noindex"><style>html,body{background:#0a0a0a;margin:0;height:100%}</style></head><body></body></html>`, `<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta http-equiv="refresh" content="0;url=${to}"><meta name="robots" content="noindex"><style>html,body{background:#0a0a0a;margin:0;height:100%}</style></head><body></body></html>`,
@ -150,10 +132,17 @@ function redirect(to: string) {
); );
} }
export function html(template: string, data: Record<string, unknown> = {}) {
return new Response(eta.render(template, {
...data, timeAgoSSR, sparklineSSR, pickBestRegion, latencyChartSSR, escapeHtmlSSR, cssHash, jsHash,
regionColors: REGION_COLORS, regionLabels: REGION_LABELS, regions: REGIONS, planLabels: PLAN_LABELS,
}), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
async function getAccountId(cookie: any, headers: any): Promise<{ accountId: string; keyId: string | null; plan: string } | null> { async function getAccountId(cookie: any, headers: any): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const authHeader = headers["authorization"] ?? ""; const key = cookie?.pingql_key?.value || headers["authorization"]?.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const key = cookie?.pingql_key?.value || bearer;
if (!key) return null; if (!key) return null;
return await resolveKey(key) ?? null; return await resolveKey(key) ?? null;
} }

View File

@ -1,21 +0,0 @@
export function createRateLimiter(windowMs = 60_000, cleanupIntervalMs = 5 * 60_000) {
const map = new Map<string, { count: number; resetAt: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, entry] of map) {
if (now > entry.resetAt) map.delete(key);
}
}, cleanupIntervalMs);
return function check(key: string, max: number): boolean {
const now = Date.now();
const entry = map.get(key);
if (!entry || now > entry.resetAt) {
map.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
entry.count++;
return entry.count <= max;
};
}

View File

@ -12,10 +12,7 @@ export function sparkline(values: number[], width = 120, height = 32, color = '#
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`; return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
} }
const REGION_COLORS: Record<string, string> = { import { REGION_COLORS } from "../../../shared/plans";
'eu-central': '#3b82f6',
'us-west': '#f59e0b',
};
// Pick the best region: the one with the lowest avg latency across its last 3 pings. // Pick the best region: the one with the lowest avg latency across its last 3 pings.
// Only considers regions that have at least one ping in the most recent 3 pings overall, // Only considers regions that have at least one ping in the most recent 3 pings overall,

View File

@ -141,8 +141,7 @@
const mins = Math.floor(remainingSec / 60); const mins = Math.floor(remainingSec / 60);
const secs = remainingSec % 60; const secs = remainingSec % 60;
const pct = Math.min(100, Math.round((received / total) * 100)); const pct = Math.min(100, Math.round((received / total) * 100));
const invPlanNames = { pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' }; const invPlanLabel = it.planLabels[inv.plan] || inv.plan;
const invPlanLabel = invPlanNames[inv.plan] || inv.plan;
%> %>
<% if (isPending) { %> <% if (isPending) { %>

View File

@ -476,15 +476,8 @@
}); });
// ── Interactive latency chart ────────────────────────────────────── // ── Interactive latency chart ──────────────────────────────────────
const REGION_COLORS = { const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>;
'eu-central': '#3b82f6', const REGION_LABELS = <%~ JSON.stringify(it.regionLabels) %>;
'us-west': '#f59e0b',
'__none__': '#6b7280'
};
const REGION_LABELS = {
'eu-central': 'EU Central',
'us-west': 'US West'
};
const MAX_RUNS = 100; const MAX_RUNS = 100;
const chartPings = <%~ JSON.stringify(chartPings.map(p => ({ const chartPings = <%~ JSON.stringify(chartPings.map(p => ({
@ -791,7 +784,7 @@
if (regions.length > 0) { if (regions.length > 0) {
html += '<div class="mt-1.5 pt-1.5 border-t border-gray-700/50">'; html += '<div class="mt-1.5 pt-1.5 border-t border-gray-700/50">';
for (const r of regions) { for (const r of regions) {
const rLabel = {'eu-central':'EU Central','us-west':'US West'}[r.region] || r.region || 'unknown'; const rLabel = REGION_LABELS[r.region] || r.region || 'unknown';
const status = r.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>'; const status = r.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>';
const lat = r.latency_ms != null ? `<span class="text-gray-400 font-mono">${r.latency_ms}ms</span>` : ''; const lat = r.latency_ms != null ? `<span class="text-gray-400 font-mono">${r.latency_ms}ms</span>` : '';
html += `<div class="flex items-center justify-between gap-3"><span>${rLabel}</span><span>${lat} ${status}</span></div>`; html += `<div class="flex items-center justify-between gap-3"><span>${rLabel}</span><span>${lat} ${status}</span></div>`;

View File

@ -80,10 +80,7 @@
} catch {} } catch {}
}, 30000); }, 30000);
const REGION_COLORS = { const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>;
'eu-central': '#3b82f6',
'us-west': '#f59e0b',
};
// Per-monitor tracking: regions = {region: [vals]}, timeline = [{region, val}] in arrival order // Per-monitor tracking: regions = {region: [vals]}, timeline = [{region, val}] in arrival order
const sparkData = {}; // mid → { regions: {region: [vals]}, timeline: [{region}] } const sparkData = {}; // mid → { regions: {region: [vals]}, timeline: [{region}] }

View File

@ -12,7 +12,7 @@
const allIntervals = [['2','2 seconds'],['5','5 seconds'],['10','10 seconds'],['20','20 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']]; const allIntervals = [['2','2 seconds'],['5','5 seconds'],['10','10 seconds'],['20','20 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']];
const intervals = allIntervals.filter(([val]) => Number(val) >= minInterval); const intervals = allIntervals.filter(([val]) => Number(val) >= minInterval);
const timeouts = [['5000','5 seconds'],['10000','10 seconds'],['20000','20 seconds'],['30000','30 seconds'],['40000','40 seconds'],['50000','50 seconds'],['60000','60 seconds']]; const timeouts = [['5000','5 seconds'],['10000','10 seconds'],['20000','20 seconds'],['30000','30 seconds'],['40000','40 seconds'],['50000','50 seconds'],['60000','60 seconds']];
const regions = [['eu-central','EU Central'],['us-west','US West']]; const regions = it.regions;
const curMethod = monitor.method || 'GET'; const curMethod = monitor.method || 'GET';
const bodyHidden = ['GET','HEAD','OPTIONS'].includes(curMethod); const bodyHidden = ['GET','HEAD','OPTIONS'].includes(curMethod);
%> %>

View File

@ -5,7 +5,7 @@
const hasEmail = !!it.account.email_hash; const hasEmail = !!it.account.email_hash;
const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const plan = it.account.plan || 'free'; const plan = it.account.plan || 'free';
const planLabel = { free: 'Free', pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' }[plan] || plan; const planLabel = it.planLabels[plan] || plan;
const limits = { const limits = {
free: { monitors: 10, interval: '30s', regions: 1 }, free: { monitors: 10, interval: '30s', regions: 1 },
pro: { monitors: 200, interval: '5s', regions: 'All' }, pro: { monitors: 200, interval: '5s', regions: 'All' },
@ -55,9 +55,8 @@
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade to Lifetime</a> <a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade to Lifetime</a>
<% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []); <% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []);
if (stack.length > 0) { if (stack.length > 0) {
const planNames = { free: 'Free', pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' };
const parts = stack.map(function(s) { const parts = stack.map(function(s) {
const name = planNames[s.plan] || s.plan; const name = it.planLabels[s.plan] || s.plan;
return s.remaining_days == null ? name : name + ' (' + s.remaining_days + 'd)'; return s.remaining_days == null ? name : name + ' (' + s.remaining_days + 'd)';
}); });
%> %>
@ -176,8 +175,7 @@
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' };
const statusColor = statusColors[inv.status] || 'gray'; const statusColor = statusColors[inv.status] || 'gray';
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
const invPlanNames = { pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' }; const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `${it.planLabels[inv.plan] || 'Pro'} × ${inv.months}mo`;
const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `${invPlanNames[inv.plan] || 'Pro'} × ${inv.months}mo`;
%> %>
<div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors"> <div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">