refactor: improve maintainability by reducing LOC/reuse
This commit is contained in:
@@ -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"],
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user