diff --git a/index.ts b/index.ts index 1680e83..0dc2b40 100644 --- a/index.ts +++ b/index.ts @@ -2,8 +2,10 @@ import { config } from "./src/config.js"; import GET from "./src/fetch/GET.js"; +import OPTIONS from "./src/fetch/OPTIONS.js"; import POST from "./src/fetch/POST.js"; import PUT from "./src/fetch/PUT.js"; +import { NotFound } from "./src/fetch/cors.js"; import { logger } from "./src/logger.js"; if (config.verbose) logger.enable(); @@ -12,10 +14,11 @@ const app = Bun.serve({ hostname: config.hostname, port: config.port, fetch(req: Request) { - if (req.method == "GET") return GET(req); - if (req.method == "POST") return POST(req); - if (req.method == "PUT") return PUT(req); - return new Response("Invalid request", { status: 400 }); + if (req.method === "GET") return GET(req); + if (req.method === "POST") return POST(req); + if (req.method === "PUT") return PUT(req); + if (req.method === "OPTIONS") return OPTIONS(req); + return NotFound; }, }); diff --git a/src/fetch/GET.ts b/src/fetch/GET.ts index 91f4977..b3765d4 100644 --- a/src/fetch/GET.ts +++ b/src/fetch/GET.ts @@ -1,20 +1,21 @@ import { file } from "bun"; import swaggerFavicon from "../../swagger/favicon.png"; import swaggerHtml from "../../swagger/index.html"; -import { registry } from "../prometheus.js"; +import { metrics } from "../prometheus.js"; import { blocks } from "./blocks.js"; +import { NotFound, toFile, toJSON } from "./cors.js"; import health from "./health.js"; import openapi from "./openapi.js"; export default async function (req: Request) { const { pathname } = new URL(req.url); - if (pathname === "/") return new Response(file(swaggerHtml)); - if (pathname === "/favicon.png") return new Response(file(swaggerFavicon)); - if (pathname === "/health") return health(req); - if (pathname === "/metrics") return new Response(await registry.metrics(), { headers: { "Content-Type": registry.contentType } }); - if (pathname === "/openapi") return new Response(openapi, { headers: { "Content-Type": "application/json" } }); + if (pathname === "/") return toFile(file(swaggerHtml)); + if (pathname === "/favicon.png") return toFile(file(swaggerFavicon)); + if (pathname === "/health") return health(); + if (pathname === "/metrics") return metrics(); + if (pathname === "/openapi") return toJSON(openapi); if (pathname === "/blocks") return blocks(); - return new Response("Not found", { status: 400 }); + return NotFound; } diff --git a/src/fetch/OPTIONS.ts b/src/fetch/OPTIONS.ts new file mode 100644 index 0000000..07dc0a3 --- /dev/null +++ b/src/fetch/OPTIONS.ts @@ -0,0 +1,5 @@ +import { CORS_HEADERS } from "./cors.js"; + +export default async function (req: Request) { + return new Response("Departed", { headers: CORS_HEADERS }); +} diff --git a/src/fetch/POST.ts b/src/fetch/POST.ts index 07c406d..e437ff1 100644 --- a/src/fetch/POST.ts +++ b/src/fetch/POST.ts @@ -1,7 +1,9 @@ import { handleSinkRequest } from "../clickhouse/handleSinkRequest.js"; +import { logger } from "../logger.js"; import { sink_request_errors, sink_requests } from "../prometheus.js"; import { BodySchema } from "../schemas.js"; import signatureEd25519 from "../webhook/signatureEd25519.js"; +import { toText } from "./cors.js"; import { query } from "./query.js"; export default async function (req: Request) { @@ -22,13 +24,14 @@ export default async function (req: Request) { const body = BodySchema.parse(JSON.parse(text)); if ("message" in body) { - if (body.message === "PING") return new Response("OK"); - return new Response("invalid body", { status: 400 }); + if (body.message === "PING") return toText("OK"); + return toText("invalid body", 400); } return handleSinkRequest(body); } catch (err) { + logger.error(err); sink_request_errors?.inc(); - return new Response("invalid request: " + JSON.stringify(err), { status: 400 }); + return toText("invalid request", 400); } } diff --git a/src/fetch/PUT.ts b/src/fetch/PUT.ts index 2d7ed3b..470d1d2 100644 --- a/src/fetch/PUT.ts +++ b/src/fetch/PUT.ts @@ -1,4 +1,5 @@ import { validateBearerAuth } from "./bearerAuth.js"; +import { NotFound } from "./cors.js"; import init from "./init.js"; import { handleSchemaRequest } from "./schema.js"; @@ -13,5 +14,5 @@ export default async function (req: Request): Promise { if (pathname === "/schema/sql") return handleSchemaRequest(req, "sql"); if (pathname === "/schema/graphql") return handleSchemaRequest(req, "graphql"); - return new Response("Not found", { status: 400 }); + return NotFound; } diff --git a/src/fetch/blocks.ts b/src/fetch/blocks.ts index d66c553..c4428db 100644 --- a/src/fetch/blocks.ts +++ b/src/fetch/blocks.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import client from "../clickhouse/createClient.js"; +import { logger } from "../logger.js"; +import { InternalServerError, toJSON } from "./cors.js"; type BlockViewType = Array<{ count: string; @@ -41,8 +43,9 @@ export async function blocks(): Promise { const dto: BlockResponseSchema = { max, min, distinctCount, delta, missing, count }; - return new Response(JSON.stringify(dto), { headers: { "Content-Type": "application/json" } }); + return toJSON(dto); } catch (err) { - return new Response(err instanceof Error ? err.message : JSON.stringify(err), { status: 500 }); + logger.error(err); + return InternalServerError; } } diff --git a/src/fetch/cors.ts b/src/fetch/cors.ts new file mode 100644 index 0000000..0b81b52 --- /dev/null +++ b/src/fetch/cors.ts @@ -0,0 +1,37 @@ +import { BunFile } from "bun"; + +export const CORS_HEADERS = new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, WWW-Authenticate", +}); +export const JSON_HEADERS = new Headers({ "Content-Type": "application/json" }); +export const TEXT_HEADERS = new Headers({ "Content-Type": "text/plain" }); + +export const BadRequest = toText("Bad Request", 400); +export const NotFound = toText("Not Found", 404); +export const InternalServerError = toText("Internal Server Error", 500); + +export function appendHeaders(...args: Headers[]) { + const headers = new Headers(CORS_HEADERS); // CORS as default headers + for (const arg of args) { + for (const [key, value] of arg.entries()) { + headers.set(key, value); + } + } + return headers; +} + +export function toJSON(body: unknown, status = 200, headers = new Headers()) { + const data = typeof body == "string" ? body : JSON.stringify(body); + return new Response(data, { status, headers: appendHeaders(JSON_HEADERS, headers) }); +} + +export function toText(body: string, status = 200, headers = new Headers()) { + return new Response(body, { status, headers: appendHeaders(TEXT_HEADERS, headers) }); +} + +export function toFile(body: BunFile, status = 200, headers = new Headers()) { + const fileHeaders = new Headers({ "Content-Type": body.type }); + return new Response(body, { status, headers: appendHeaders(fileHeaders, headers) }); +} diff --git a/src/fetch/health.ts b/src/fetch/health.ts index fe8f0b2..35e12a5 100644 --- a/src/fetch/health.ts +++ b/src/fetch/health.ts @@ -1,11 +1,17 @@ import client from "../clickhouse/createClient.js"; +import { logger } from "../logger.js"; +import { BadRequest, toText } from "./cors.js"; -export default async function (req: Request) { +export default async function () { try { const response = await client.ping(); - if (response.success === false) throw new Error(response.error.message); - return new Response("OK"); - } catch (e: any) { - return new Response(e.message, { status: 400 }); + if (!response.success) { + throw new Error(response.error.message); + } + + return toText("OK"); + } catch (e) { + logger.error(e); + return BadRequest; } } diff --git a/src/fetch/init.ts b/src/fetch/init.ts index abacde1..39b0a63 100644 --- a/src/fetch/init.ts +++ b/src/fetch/init.ts @@ -2,14 +2,17 @@ import { createDatabase } from "../clickhouse/createDatabase.js"; import { ping } from "../clickhouse/ping.js"; import { initializeDefaultTables } from "../clickhouse/table-initialization.js"; import { config } from "../config.js"; +import { logger } from "../logger.js"; +import { BadRequest, toText } from "./cors.js"; export default async function () { try { await ping(); await createDatabase(config.database); await initializeDefaultTables(); - return new Response("OK"); + return toText("OK"); } catch (e) { - return new Response(e instanceof Error ? e.message : JSON.stringify(e), { status: 400 }); + logger.error(e); + return BadRequest; } } diff --git a/src/fetch/query.ts b/src/fetch/query.ts index d5a0f90..a6f91b4 100644 --- a/src/fetch/query.ts +++ b/src/fetch/query.ts @@ -1,4 +1,6 @@ import { readOnlyClient } from "../clickhouse/createClient.js"; +import { logger } from "../logger.js"; +import { BadRequest, toJSON } from "./cors.js"; export async function query(req: Request): Promise { try { @@ -6,11 +8,9 @@ export async function query(req: Request): Promise { const result = await readOnlyClient.query({ query, format: "JSONEachRow" }); const data = await result.json(); - return new Response(JSON.stringify(data), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return toJSON(data); } catch (err) { - return new Response("Bad request: " + err, { status: 400 }); + logger.error(err); + return BadRequest; } } diff --git a/src/fetch/schema.ts b/src/fetch/schema.ts index 74fcf15..4e0c031 100644 --- a/src/fetch/schema.ts +++ b/src/fetch/schema.ts @@ -4,18 +4,19 @@ import { ClickhouseTableBuilder } from "../graphql/builders/clickhouse-table-bui import { TableTranslator } from "../graphql/table-translator.js"; import { logger } from "../logger.js"; import { TableInitSchema } from "../schemas.js"; +import { toJSON, toText } from "./cors.js"; const clickhouseBuilder = new ClickhouseTableBuilder(); export async function handleSchemaRequest(req: Request, type: "sql" | "graphql") { const body = await req.text(); if (!body) { - return new Response("missing body", { status: 400 }); + return toText("missing body", 400); } const result = TableInitSchema.safeParse(body); if (!result.success) { - return new Response(result.error.toString(), { status: 400 }); + return toText("Bad request: " + result.error.toString(), 400); } let tableSchemas: string[] = []; @@ -29,10 +30,9 @@ export async function handleSchemaRequest(req: Request, type: "sql" | "graphql") try { await initializeTables(tableSchemas); - return new Response(JSON.stringify({ status: "OK", schema: tableSchemas.join("\n\n") }), { - headers: { "Content-Type": "application/json" }, - }); + return toJSON({ status: "OK", schema: tableSchemas.join("\n\n") }); } catch (err) { - return new Response(`Could not create the tables: ${err}`, { status: 500 }); + logger.error(err); + return toText("Could not create the tables", 500); } } diff --git a/src/prometheus.ts b/src/prometheus.ts index 2c8f719..f2b4f68 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -2,6 +2,12 @@ import client, { Counter, Gauge } from "prom-client"; export const registry = new client.Registry(); +export async function metrics() { + const headers = new Headers(); + headers.set("Content-Type", registry.contentType); + return new Response(await registry.metrics(), { status: 200, headers }); +} + export function registerCounter(name: string, help: string) { try { registry.registerMetric(new client.Counter({ name, help })); diff --git a/src/webhook/signatureEd25519.ts b/src/webhook/signatureEd25519.ts index f5ba3ae..fabc610 100644 --- a/src/webhook/signatureEd25519.ts +++ b/src/webhook/signatureEd25519.ts @@ -1,16 +1,17 @@ import { config } from "../config.js"; +import { toText } from "../fetch/cors.js"; import { verify } from "./verify.js"; export default async function (req: Request, text: string) { - const timestamp = req.headers.get("x-signature-timestamp"); - const signature = req.headers.get("x-signature-ed25519"); + const timestamp = req.headers.get("x-signature-timestamp"); + const signature = req.headers.get("x-signature-ed25519"); - if (!timestamp) return new Response("missing required timestamp in headers", {status: 400}); - if (!signature) return new Response("missing required signature in headers", {status: 400 }); - if (!text) return new Response("missing body", { status: 400 }); + if (!timestamp) return toText("missing required timestamp in headers", 400); + if (!signature) return toText("missing required signature in headers", 400); + if (!text) return toText("missing body", 400); - const msg = Buffer.from(timestamp + text); - const isVerified = verify(msg, signature, config.publicKey); + const msg = Buffer.from(timestamp + text); + const isVerified = verify(msg, signature, config.publicKey); - if (!isVerified) return new Response("invalid request signature", { status: 401 }); + if (!isVerified) return toText("invalid request signature", 401); }