diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index 038dab4..77f23a7 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -1,4 +1,5 @@ import postgres from "postgres"; +import { migrate as sharedMigrate } from "../../shared/db"; const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { max: 20, @@ -9,80 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local export default sql; export async function migrate() { - await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`; - 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"); + await sharedMigrate(sql); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 61c372e..20fb822 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,19 +1,12 @@ import { Elysia } from "elysia"; - import { ingest } from "./routes/pings"; import { monitors } from "./routes/monitors"; import { account } from "./routes/auth"; import { internal } from "./routes/internal"; import { migrate } from "./db"; -await migrate(); +import { SECURITY_HEADERS } from "../../shared/auth"; -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", -}; +await migrate(); const elysia = new Elysia() .get("/", () => ({ diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index f3053f4..d11c716 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,33 +1,13 @@ import { Elysia, t } from "elysia"; -import { createHmac, randomBytes } from "crypto"; import sql from "../db"; -import { createRateLimiter } from "../utils/rate-limit"; -import { getPlanLimits } from "../utils/plans"; +import { createRateLimiter } from "../../../shared/rate-limit"; +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 EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-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; +async function resolveKey(key: string) { + return sharedResolveKey(sql, key); } export { resolveKey }; @@ -35,11 +15,7 @@ export { resolveKey }; export function requireAuth(app: Elysia) { return app .derive(async ({ headers, cookie, set }) => { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const cookieKey = cookie?.pingql_key?.value; - - const key = bearer || cookieKey; + const key = extractAuthKey(headers, cookie); if (!key) { set.status = 401; 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" }) .post("/login", async ({ body, cookie, set, request }) => { diff --git a/apps/api/src/routes/internal.ts b/apps/api/src/routes/internal.ts index 9ea4f88..f029756 100644 --- a/apps/api/src/routes/internal.ts +++ b/apps/api/src/routes/internal.ts @@ -3,7 +3,7 @@ import { Elysia } from "elysia"; import sql from "../db"; -import { safeTokenCompare } from "../utils/token"; +import { safeTokenCompare } from "../../../shared/auth"; export async function pruneOldPings(retentionDays = 90) { const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`; diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 3e02bb3..dce4e6f 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"; import { requireAuth } from "./auth"; import sql from "../db"; import { validateMonitorUrl } from "../utils/ssrf"; -import { getPlanLimits } from "../utils/plans"; +import { getPlanLimits } from "../../../shared/plans"; const MonitorBody = t.Object({ name: t.String({ maxLength: 200, description: "Human-readable name" }), diff --git a/apps/api/src/routes/pings.ts b/apps/api/src/routes/pings.ts index 8b37819..f743081 100644 --- a/apps/api/src/routes/pings.ts +++ b/apps/api/src/routes/pings.ts @@ -1,7 +1,7 @@ import { Elysia, t } from "elysia"; import sql from "../db"; import { resolveKey } from "./auth"; -import { safeTokenCompare } from "../utils/token"; +import { extractAuthKey, safeTokenCompare } from "../../../shared/auth"; // ── SSE bus ─────────────────────────────────────────────────────────────────── type SSEController = ReadableStreamDefaultController; @@ -121,9 +121,7 @@ export const ingest = new Elysia() // Fetch response body for a specific ping .get("/pings/:id/body", async ({ params, headers, cookie, set }) => { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const key = bearer ?? cookie?.pingql_key?.value; + const key = extractAuthKey(headers, cookie); if (!key) { set.status = 401; return { error: "Unauthorized" }; } const resolved = await resolveKey(key); @@ -143,10 +141,7 @@ export const ingest = new Elysia() // SSE: single stream for all of the account's monitors .get("/account/stream", async ({ headers, cookie }) => { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const key = bearer ?? cookie?.pingql_key?.value; - + const key = extractAuthKey(headers, cookie); if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); const resolved = await resolveKey(key); diff --git a/apps/api/src/utils/plans.ts b/apps/api/src/utils/plans.ts deleted file mode 100644 index c5a8ecd..0000000 --- a/apps/api/src/utils/plans.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime"; - -export interface PlanLimits { - maxMonitors: number; - minIntervalS: number; - maxRegions: number; -} - -const PLANS: Record = { - 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 = { - 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 = { - free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3, -}; - -export function planTier(plan: string): number { - return PLAN_RANK[plan] ?? 0; -} diff --git a/apps/api/src/utils/token.ts b/apps/api/src/utils/token.ts deleted file mode 100644 index 01c58f6..0000000 --- a/apps/api/src/utils/token.ts +++ /dev/null @@ -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); -} diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts index 51884f9..a0ac114 100644 --- a/apps/pay/src/db.ts +++ b/apps/pay/src/db.ts @@ -1,4 +1,5 @@ import postgres from "postgres"; +import { migrate as sharedMigrate } from "../../shared/db"; const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { max: 10, @@ -9,53 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local export default sql; export async function migrate() { - // Plan columns on accounts (may already exist from API/web migrations) - 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"); + await sharedMigrate(sql); } diff --git a/apps/pay/src/index.ts b/apps/pay/src/index.ts index b12e258..eedae44 100644 --- a/apps/pay/src/index.ts +++ b/apps/pay/src/index.ts @@ -5,12 +5,7 @@ import { checkPayments, expireProPlans } from "./monitor"; await migrate(); -const SECURITY_HEADERS = { - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Strict-Transport-Security": "max-age=63072000; includeSubDomains", - "Referrer-Policy": "strict-origin-when-cross-origin", -}; +import { SECURITY_HEADERS } from "../../shared/auth"; const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"]; diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index 02c8e6f..46ea6b7 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -2,7 +2,7 @@ /// States: pending → underpaid → confirming → paid | expired import sql from "./db"; import { getAddressInfo, getAddressInfoBulk } from "./freedom"; -import { COINS } from "./plans"; +import { COINS, planTier } from "../../shared/plans"; import { generateReceipt } from "./receipt"; 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 AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] } -const PLAN_RANK: Record = { 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[] { const result = stack.slice(); // Merge if same plan already exists diff --git a/apps/pay/src/plans.ts b/apps/pay/src/plans.ts deleted file mode 100644 index b914a01..0000000 --- a/apps/pay/src/plans.ts +++ /dev/null @@ -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 = { - 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 = { - 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" }, -}; diff --git a/apps/pay/src/receipt.ts b/apps/pay/src/receipt.ts index a06aadb..1517da2 100644 --- a/apps/pay/src/receipt.ts +++ b/apps/pay/src/receipt.ts @@ -1,5 +1,5 @@ import sql from "./db"; -import { COINS } from "./plans"; +import { COINS } from "../../shared/plans"; export async function generateReceipt(paymentId: number): Promise { const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`; diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts index 4790b03..3b49deb 100644 --- a/apps/pay/src/routes.ts +++ b/apps/pay/src/routes.ts @@ -2,33 +2,20 @@ import { Elysia, t } from "elysia"; import sql from "./db"; import { derive } from "./address"; 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 { watchPayment } from "./monitor"; - -// 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; -} +import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth"; function requireAuth(app: Elysia) { return app .derive(async ({ headers, cookie, set }) => { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const cookieKey = cookie?.pingql_key?.value; - const key = bearer || cookieKey; + const key = extractAuthKey(headers, cookie); if (!key) { set.status = 401; 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; set.status = 401; return { accountId: null as string | null, keyId: null as string | null, plan: "free" }; diff --git a/apps/shared/auth.ts b/apps/shared/auth.ts new file mode 100644 index 0000000..494c45a --- /dev/null +++ b/apps/shared/auth.ts @@ -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, 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", +}; diff --git a/apps/shared/db.ts b/apps/shared/db.ts new file mode 100644 index 0000000..8492e36 --- /dev/null +++ b/apps/shared/db.ts @@ -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"); +} diff --git a/apps/shared/plans.ts b/apps/shared/plans.ts new file mode 100644 index 0000000..eb9776d --- /dev/null +++ b/apps/shared/plans.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + "eu-central": "#3b82f6", + "us-west": "#f59e0b", + "__none__": "#6b7280", +}; + +export const REGION_LABELS: Record = { + "eu-central": "EU Central", + "us-west": "US West", +}; + +export const REGIONS: [string, string][] = [ + ["eu-central", "EU Central"], + ["us-west", "US West"], +]; diff --git a/apps/api/src/utils/rate-limit.ts b/apps/shared/rate-limit.ts similarity index 100% rename from apps/api/src/utils/rate-limit.ts rename to apps/shared/rate-limit.ts diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 8df4ce0..77f23a7 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -1,4 +1,5 @@ import postgres from "postgres"; +import { migrate as sharedMigrate } from "../../shared/db"; const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { max: 20, @@ -9,77 +10,5 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local export default sql; export async function migrate() { - await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`; - 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"); + await sharedMigrate(sql); } diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 47d6be4..0dfc98b 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -6,13 +6,7 @@ import { migrate } from "./db"; await migrate(); -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", -}; +import { SECURITY_HEADERS } from "../../shared/auth"; const app = new Elysia() .onAfterHandle(({ set }) => { diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index c1d155c..bf5d6bb 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -1,9 +1,7 @@ import { Elysia, t } from "elysia"; -import { createHmac, randomBytes } from "crypto"; import sql from "../db"; -import { createRateLimiter } from "../utils/rate-limit"; - -const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key"; +import { createRateLimiter } from "../../../shared/rate-limit"; +import { generateKey, hashEmail, resolveKey as sharedResolveKey, extractAuthKey, COOKIE_OPTS } from "../../../shared/auth"; function redir(to: string) { return new Response( @@ -12,28 +10,10 @@ function redir(to: string) { ); } -// ── Per-IP rate limiting for auth endpoints ─────────────────────────── const checkAuthRateLimit = createRateLimiter(); -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; +async function resolveKey(key: string) { + return sharedResolveKey(sql, key); } export { resolveKey }; @@ -41,11 +21,7 @@ export { resolveKey }; export function requireAuth(app: Elysia) { return app .derive(async ({ headers, cookie, set }) => { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const cookieKey = cookie?.pingql_key?.value; - - const key = bearer || cookieKey; + const key = extractAuthKey(headers, cookie); if (!key) { set.status = 401; 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" }) .post("/login", async ({ body, cookie, set, request, error }) => { diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 2402ecb..55b015a 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -5,8 +5,8 @@ import { resolveKey } from "./auth"; import sql from "../db"; import { sparkline, sparklineFromPings, pickBestRegion } from "../utils/sparkline"; 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 cssHash = createHash("md5").update(await cssFile.bytes()).digest("hex").slice(0, 8); 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 REGION_COLORS: Record = { - 'eu-central': '#3b82f6', // blue - 'us-west': '#f59e0b', // amber - '__none__': '#6b7280', // gray for null region -}; - -const REGION_LABELS: Record = { - 'eu-central': '🇩🇪 EU', - 'us-west': '🇺🇸 US-W', - '__none__': '?', -}; - function latencyChartSSR(pings: any[]): string { const data = pings.filter((c: any) => c.latency_ms != null); if (data.length < 2) { @@ -137,12 +125,6 @@ function escapeHtmlSSR(str: string): string { return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } -export function html(template: string, data: Record = {}) { - 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) { return new Response( ``, @@ -150,10 +132,17 @@ function redirect(to: string) { ); } +export function html(template: string, data: Record = {}) { + 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> { - const authHeader = headers["authorization"] ?? ""; - const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - const key = cookie?.pingql_key?.value || bearer; + const key = cookie?.pingql_key?.value || headers["authorization"]?.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); if (!key) return null; return await resolveKey(key) ?? null; } diff --git a/apps/web/src/utils/rate-limit.ts b/apps/web/src/utils/rate-limit.ts deleted file mode 100644 index 7d89a39..0000000 --- a/apps/web/src/utils/rate-limit.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function createRateLimiter(windowMs = 60_000, cleanupIntervalMs = 5 * 60_000) { - const map = new Map(); - - 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; - }; -} diff --git a/apps/web/src/utils/sparkline.ts b/apps/web/src/utils/sparkline.ts index 4f6b3d4..ce9703b 100644 --- a/apps/web/src/utils/sparkline.ts +++ b/apps/web/src/utils/sparkline.ts @@ -12,10 +12,7 @@ export function sparkline(values: number[], width = 120, height = 32, color = '# return ``; } -const REGION_COLORS: Record = { - 'eu-central': '#3b82f6', - 'us-west': '#f59e0b', -}; +import { REGION_COLORS } from "../../../shared/plans"; // 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, diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index f01a783..704b449 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -141,8 +141,7 @@ const mins = Math.floor(remainingSec / 60); const secs = remainingSec % 60; const pct = Math.min(100, Math.round((received / total) * 100)); - const invPlanNames = { pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' }; - const invPlanLabel = invPlanNames[inv.plan] || inv.plan; + const invPlanLabel = it.planLabels[inv.plan] || inv.plan; %> <% if (isPending) { %> diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 57b7b83..ce728d1 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -476,15 +476,8 @@ }); // ── Interactive latency chart ────────────────────────────────────── - const REGION_COLORS = { - 'eu-central': '#3b82f6', - 'us-west': '#f59e0b', - '__none__': '#6b7280' - }; - const REGION_LABELS = { - 'eu-central': 'EU Central', - 'us-west': 'US West' - }; + const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>; + const REGION_LABELS = <%~ JSON.stringify(it.regionLabels) %>; const MAX_RUNS = 100; const chartPings = <%~ JSON.stringify(chartPings.map(p => ({ @@ -791,7 +784,7 @@ if (regions.length > 0) { html += '
'; 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 ? 'Up' : 'Down'; const lat = r.latency_ms != null ? `${r.latency_ms}ms` : ''; html += `
${rLabel}${lat} ${status}
`; diff --git a/apps/web/src/views/home.ejs b/apps/web/src/views/home.ejs index 2504654..1815988 100644 --- a/apps/web/src/views/home.ejs +++ b/apps/web/src/views/home.ejs @@ -80,10 +80,7 @@ } catch {} }, 30000); - const REGION_COLORS = { - 'eu-central': '#3b82f6', - 'us-west': '#f59e0b', - }; + const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>; // Per-monitor tracking: regions = {region: [vals]}, timeline = [{region, val}] in arrival order const sparkData = {}; // mid → { regions: {region: [vals]}, timeline: [{region}] } diff --git a/apps/web/src/views/partials/monitor-form.ejs b/apps/web/src/views/partials/monitor-form.ejs index 5fb50fd..a53c653 100644 --- a/apps/web/src/views/partials/monitor-form.ejs +++ b/apps/web/src/views/partials/monitor-form.ejs @@ -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 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 regions = [['eu-central','EU Central'],['us-west','US West']]; + const regions = it.regions; const curMethod = monitor.method || 'GET'; const bodyHidden = ['GET','HEAD','OPTIONS'].includes(curMethod); %> diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index ef20ce9..d551207 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -5,7 +5,7 @@ const hasEmail = !!it.account.email_hash; const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); 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 = { free: { monitors: 10, interval: '30s', regions: 1 }, pro: { monitors: 200, interval: '5s', regions: 'All' }, @@ -55,9 +55,8 @@ Extend or upgrade to Lifetime <% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []); 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 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)'; }); %> @@ -176,8 +175,7 @@ const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; const statusColor = statusColors[inv.status] || 'gray'; 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' : `${invPlanNames[inv.plan] || 'Pro'} × ${inv.months}mo`; + const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `${it.planLabels[inv.plan] || 'Pro'} × ${inv.months}mo`; %>