From 841a8524914af12e009553b439fd8a48392e92a6 Mon Sep 17 00:00:00 2001 From: M1 Date: Wed, 18 Mar 2026 09:33:46 +0400 Subject: [PATCH] feat: split web and api into separate apps --- apps/api/package.json | 17 ++ apps/api/src/db.ts | 68 +++++ apps/api/src/index.ts | 27 ++ apps/api/src/query/index.ts | 330 +++++++++++++++++++++++ apps/api/src/routes/auth.ts | 148 ++++++++++ apps/{web => api}/src/routes/internal.ts | 0 apps/{web => api}/src/routes/monitors.ts | 0 apps/{web => api}/src/routes/pings.ts | 0 apps/{web => api}/src/utils/ssrf.ts | 0 apps/web/src/index.ts | 37 +-- 10 files changed, 593 insertions(+), 34 deletions(-) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/db.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/query/index.ts create mode 100644 apps/api/src/routes/auth.ts rename apps/{web => api}/src/routes/internal.ts (100%) rename apps/{web => api}/src/routes/monitors.ts (100%) rename apps/{web => api}/src/routes/pings.ts (100%) rename apps/{web => api}/src/utils/ssrf.ts (100%) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..ac724d5 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pingql/api", + "version": "0.1.0", + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@elysiajs/cors": "^1.4.1", + "elysia": "^1.4.27", + "postgres": "^3.4.8" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "typescript": "^5.9.3" + } +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..efe07e9 --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,68 @@ +import postgres from "postgres"; + +const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql"); + +export default sql; + +export async function migrate() { + 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 gen_random_uuid()::text, + 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`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)`; + + 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"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..bb572a0 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,27 @@ +import { Elysia } from "elysia"; +import { cors } from "@elysiajs/cors"; +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(); + +const app = new Elysia() + .use(cors({ + origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"], + credentials: true, + })) + .get("/", () => ({ + name: "PingQL API", + version: "1", + docs: "https://pingql.com/docs", + })) + .use(account) + .use(monitors) + .use(ingest) + .use(internal) + .listen(3001); + +console.log(`PingQL API running at http://localhost:${app.server?.port}`); diff --git a/apps/api/src/query/index.ts b/apps/api/src/query/index.ts new file mode 100644 index 0000000..81d609b --- /dev/null +++ b/apps/api/src/query/index.ts @@ -0,0 +1,330 @@ +/** + * PingQL Query Engine — TypeScript implementation + * + * MongoDB-inspired query language for evaluating HTTP response conditions. + * Mirrors the Rust implementation but also powers the visual query builder. + */ + +// ── Types ────────────────────────────────────────────────────────────── + +export interface QueryField { + name: string; + description: string; + type: "number" | "string" | "boolean" | "object"; + operators: string[]; +} + +export interface EvalContext { + status: number; + body: string; + headers: Record; + latency_ms?: number; + cert_expiry_days?: number; +} + +export interface ValidationError { + path: string; + message: string; +} + +// ── Available fields ─────────────────────────────────────────────────── + +export function getAvailableFields(): QueryField[] { + return [ + { + name: "status", + description: "HTTP status code (e.g. 200, 404, 500)", + type: "number", + operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"], + }, + { + name: "body", + description: "Response body as text", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], + }, + { + name: "headers.*", + description: "Response header value (e.g. headers.content-type)", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], + }, + { + name: "$select", + description: "CSS selector — returns text content of first matching element", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"], + }, + { + name: "$json", + description: "JSONPath expression evaluated against response body (e.g. $.data.status)", + type: "string", + operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"], + }, + { + name: "$responseTime", + description: "Request latency in milliseconds", + type: "number", + operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], + }, + { + name: "$certExpiry", + description: "Days until SSL certificate expires", + type: "number", + operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], + }, + ]; +} + +// ── Evaluate ─────────────────────────────────────────────────────────── + +export function evaluate(query: unknown, ctx: EvalContext): boolean { + if (query === null || query === undefined) return true; + if (typeof query !== "object" || Array.isArray(query)) { + throw new Error("Query must be an object"); + } + + const q = query as Record; + + // $consider — "up" (default) or "down": flips result if conditions match + if ("$consider" in q) { + const consider = q.$consider as string; + const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider")); + const matches = evaluate(rest, ctx); + return consider === "down" ? !matches : matches; + } + + // $and + if ("$and" in q) { + const clauses = q.$and; + if (!Array.isArray(clauses)) throw new Error("$and expects array"); + return clauses.every((c) => evaluate(c, ctx)); + } + + // $or + if ("$or" in q) { + const clauses = q.$or; + if (!Array.isArray(clauses)) throw new Error("$or expects array"); + return clauses.some((c) => evaluate(c, ctx)); + } + + // $not + if ("$not" in q) { + return !evaluate(q.$not, ctx); + } + + // $responseTime + if ("$responseTime" in q) { + const val = ctx.latency_ms ?? 0; + return evalCondition(q.$responseTime, val); + } + + // $certExpiry + if ("$certExpiry" in q) { + const val = ctx.cert_expiry_days ?? Infinity; + return evalCondition(q.$certExpiry, val); + } + + // $select — { "$select": { "css.selector": { "$op": val } } } + if ("$select" in q) { + // Server-side: no DOM parser available, pass through (Rust runner evaluates) + // Validate structure only + const selMap = q.$select as Record; + if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }"); + return true; + } + + // $json — { "$json": { "$.path": { "$op": val } } } + if ("$json" in q) { + const pathMap = q.$json as Record; + if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }"); + for (const [path, condition] of Object.entries(pathMap)) { + const resolved = resolveJsonPath(ctx.body, path); + if (!evalCondition(condition, resolved)) return false; + } + return true; + } + + // Field-level checks + for (const [field, condition] of Object.entries(q)) { + if (field.startsWith("$")) continue; // skip unknown $ ops + const fieldVal = resolveField(field, ctx); + if (!evalCondition(condition, fieldVal)) return false; + } + + return true; +} + +function resolveField(field: string, ctx: EvalContext): unknown { + switch (field) { + case "status": + case "status_code": + return ctx.status; + case "body": + return ctx.body; + default: + if (field.startsWith("headers.")) { + const key = field.slice(8).toLowerCase(); + return ctx.headers[key] ?? null; + } + return null; + } +} + +function resolveJsonPath(body: string, expr: string): unknown { + try { + const obj = JSON.parse(body); + // Simple dot-notation JSONPath: $.foo.bar[0].baz + const path = expr.replace(/^\$\.?/, ""); + if (!path) return obj; + const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined) return null; + current = (current as Record)[part]; + } + return current ?? null; + } catch { + return null; + } +} + +function evalCondition(condition: unknown, fieldVal: unknown): boolean { + if (condition === null || condition === undefined) return true; + + // Direct equality shorthand: { "status": 200 } + if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") { + return fieldVal === condition; + } + + if (typeof condition === "object" && !Array.isArray(condition)) { + const ops = condition as Record; + for (const [op, opVal] of Object.entries(ops)) { + if (!evalOp(op, fieldVal, opVal)) return false; + } + return true; + } + + return true; +} + +function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { + switch (op) { + case "$eq": + return fieldVal === opVal; + case "$ne": + return fieldVal !== opVal; + case "$gt": + return toNum(fieldVal) > toNum(opVal); + case "$gte": + return toNum(fieldVal) >= toNum(opVal); + case "$lt": + return toNum(fieldVal) < toNum(opVal); + case "$lte": + return toNum(fieldVal) <= toNum(opVal); + case "$contains": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal); + case "$startsWith": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal); + case "$endsWith": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal); + case "$regex": { + if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; + if (opVal.length > 200) return false; + try { + return new RegExp(opVal).test(fieldVal); + } catch { + return false; + } + } + case "$exists": + return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined; + case "$in": + return Array.isArray(opVal) && opVal.includes(fieldVal); + default: + return true; // unknown op — skip + } +} + +function toNum(v: unknown): number { + return typeof v === "number" ? v : Number(v) || 0; +} + +// ── Validate ─────────────────────────────────────────────────────────── + +const VALID_OPS = new Set([ + "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", + "$contains", "$startsWith", "$endsWith", "$regex", + "$exists", "$in", + "$select", "$json", + "$and", "$or", "$not", + "$responseTime", "$certExpiry", +]); + +const VALID_FIELDS = new Set([ + "status", "status_code", "body", +]); + +export function validateQuery(query: unknown, path = ""): ValidationError[] { + const errors: ValidationError[] = []; + + if (query === null || query === undefined) return errors; + + if (typeof query !== "object" || Array.isArray(query)) { + errors.push({ path: path || "$", message: "Query must be an object" }); + return errors; + } + + const q = query as Record; + + for (const [key, value] of Object.entries(q)) { + const keyPath = path ? `${path}.${key}` : key; + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + errors.push({ path: keyPath, message: `${key} expects an array` }); + } else { + value.forEach((clause, i) => { + errors.push(...validateQuery(clause, `${keyPath}[${i}]`)); + }); + } + } else if (key === "$not") { + errors.push(...validateQuery(value, keyPath)); + } else if (key === "$responseTime" || key === "$certExpiry") { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` }); + } else { + for (const op of Object.keys(value as Record)) { + if (!VALID_OPS.has(op)) { + errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); + } + } + } + } else if (key === "$select" || key === "$json") { + if (typeof value !== "string") { + errors.push({ path: keyPath, message: `${key} expects a string` }); + } + } else if (key.startsWith("$")) { + // It's an operator inside a field condition — skip validation here + } else { + // Field name + if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) { + errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` }); + } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const ops = value as Record; + for (const op of Object.keys(ops)) { + if (!op.startsWith("$")) continue; + if (!VALID_OPS.has(op)) { + errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); + } + if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) { + errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" }); + } + } + } + } + } + + return errors; +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..5bb0814 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,148 @@ +import { Elysia, t } from "elysia"; +import { createHash } from "crypto"; +import sql from "../db"; + +function generateKey(): string { + return crypto.randomUUID(); +} + +function hashEmail(email: string): string { + return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); +} + +async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { + const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`; + if (account) return { accountId: account.id, keyId: null }; + + const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE 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 }; + } + + return null; +} + +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; + if (!key) { + set.status = 401; + return { accountId: null as string | null, keyId: null as string | null }; + } + + const resolved = await resolveKey(key); + if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId }; + + set.status = 401; + return { accountId: null as string | null, keyId: null as string | null }; + }) + .onBeforeHandle(({ accountId, set }) => { + if (!accountId) { + set.status = 401; + return { error: "Invalid or missing account key" }; + } + }); +} + +const COOKIE_OPTS = { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + sameSite: "lax" as const, + path: "/", + domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", + maxAge: 60 * 60 * 24 * 365, +}; + +export const account = new Elysia({ prefix: "/account" }) + + .post("/login", async ({ body, cookie, set }) => { + const key = (body.key as string)?.trim(); + if (!key) { set.status = 400; return { error: "Key required" }; } + + const resolved = await resolveKey(key); + if (!resolved) { + set.status = 401; + if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; } + return { error: "Invalid account key" }; + } + + cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); + if ((body as any)._form) { set.redirect = "/dashboard/home"; return; } + return { ok: true }; + }, { detail: { hide: true } }) + + .get("/logout", ({ cookie, set }) => { + cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 }); + set.redirect = "/dashboard"; + }, { detail: { hide: true } }) + + .post("/register", async ({ body, cookie }) => { + const key = generateKey(); + const emailHash = body.email ? hashEmail(body.email) : null; + await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`; + cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); + return { + key, + ...(body.email ? { email_registered: true } : { email_registered: false }), + }; + }, { + body: t.Object({ + email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })), + }), + }) + + .use(requireAuth) + + .get("/settings", async ({ accountId }) => { + const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; + const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; + return { + account_id: acc.id, + has_email: !!acc.email_hash, + created_at: acc.created_at, + api_keys: keys, + }; + }) + + .post("/email", async ({ accountId, body }) => { + const emailHash = body.email ? hashEmail(body.email) : null; + await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`; + return { ok: true }; + }, { + body: t.Object({ + email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))), + }), + }) + + .post("/reset-key", async ({ accountId, cookie }) => { + const key = generateKey(); + await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`; + cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); + return { key, message: "Primary key rotated. Your old key is now invalid." }; + }) + + .post("/keys", async ({ accountId, body }) => { + const key = generateKey(); + const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`; + return { key, id: created.id, label: body.label }; + }, { + body: t.Object({ + label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }), + }), + }) + + .delete("/keys/:id", async ({ accountId, params, error }) => { + const [deleted] = await sql` + DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id + `; + if (!deleted) return error(404, { error: "Key not found" }); + return { deleted: true }; + }); diff --git a/apps/web/src/routes/internal.ts b/apps/api/src/routes/internal.ts similarity index 100% rename from apps/web/src/routes/internal.ts rename to apps/api/src/routes/internal.ts diff --git a/apps/web/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts similarity index 100% rename from apps/web/src/routes/monitors.ts rename to apps/api/src/routes/monitors.ts diff --git a/apps/web/src/routes/pings.ts b/apps/api/src/routes/pings.ts similarity index 100% rename from apps/web/src/routes/pings.ts rename to apps/api/src/routes/pings.ts diff --git a/apps/web/src/utils/ssrf.ts b/apps/api/src/utils/ssrf.ts similarity index 100% rename from apps/web/src/utils/ssrf.ts rename to apps/api/src/utils/ssrf.ts diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 5caf04d..b9a6334 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -1,49 +1,18 @@ import { Elysia } from "elysia"; import { cors } from "@elysiajs/cors"; -import { ingest } from "./routes/pings"; -import { monitors } from "./routes/monitors"; -import { account } from "./routes/auth"; -import { internal } from "./routes/internal"; import { dashboard } from "./routes/dashboard"; +import { account } from "./routes/auth"; import { migrate } from "./db"; await migrate(); -// Web-only paths that shouldn't be accessible via api.pingql.com -const WEB_ONLY_PATHS = ["/", "/docs", "/privacy", "/tos", "/dashboard"]; - const app = new Elysia() .use(cors({ - origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com", "https://api.pingql.com"], + origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"], credentials: true, })) - // Host-based routing: api.pingql.com gets JSON-only responses - .onBeforeHandle(({ request, set }) => { - const host = new URL(request.url).hostname; - if (host === "api.pingql.com") { - const path = new URL(request.url).pathname; - if (path === "/") { - set.headers["content-type"] = "application/json"; - return new Response(JSON.stringify({ - name: "PingQL API", - version: "1", - docs: "https://pingql.com/docs", - }), { status: 200, headers: { "content-type": "application/json" } }); - } - const isWebOnly = WEB_ONLY_PATHS.some(p => p !== "/" && path.startsWith(p)); - if (isWebOnly) { - return new Response(JSON.stringify({ error: "Not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - } - } - }) .use(dashboard) .use(account) - .use(monitors) - .use(ingest) - .use(internal) .listen(3000); -console.log(`PingQL running at http://localhost:${app.server?.port}`); +console.log(`PingQL Web running at http://localhost:${app.server?.port}`);