refactor: improve maintainability by reducing LOC/reuse
This commit is contained in:
parent
8831c9c7b4
commit
6dcb5c0a52
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("/", () => ({
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array>;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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[] {
|
||||
const result = stack.slice();
|
||||
// Merge if same plan already exists
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import sql from "./db";
|
||||
import { COINS } from "./plans";
|
||||
import { COINS } from "../../shared/plans";
|
||||
|
||||
export async function generateReceipt(paymentId: number): Promise<string> {
|
||||
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"],
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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) {
|
||||
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>`,
|
||||
|
|
@ -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> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
const REGION_COLORS: Record<string, string> = {
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -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) { %>
|
||||
|
|
|
|||
|
|
@ -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 += '<div class="mt-1.5 pt-1.5 border-t border-gray-700/50">';
|
||||
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 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>`;
|
||||
|
|
|
|||
|
|
@ -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}] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
%>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<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 || []);
|
||||
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`;
|
||||
%>
|
||||
<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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue