diff --git a/deno.json b/deno.json index 30c590d..e390aa3 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ ".": "./main.ts", "./cli": "./lib/cli/mod.ts", "./common": "./lib/common/mod.ts", - "./http": "./lib/http/mod.tsx" + "./http": "./lib/http/mod.ts" }, "tasks": { "dev": "deno run --watch --env --unstable-kv -A main.ts", diff --git a/lib/cli/server.ts b/lib/cli/server.ts index 48fbe29..705258b 100644 --- a/lib/cli/server.ts +++ b/lib/cli/server.ts @@ -1,6 +1,6 @@ import { z } from "@collinhacks/zod"; import { CONFIG, logPaths } from "$common/mod.ts"; -import { httpApp } from "$http/mod.tsx"; +import { httpApp } from "../http/mod.ts"; import metadata from "$/deno.json" with { type: "json" }; const serverArgs = z.object({ diff --git a/lib/http/asn-metadata-from-context.ts b/lib/http/asn-metadata-from-context.ts new file mode 100644 index 0000000..8cb70a4 --- /dev/null +++ b/lib/http/asn-metadata-from-context.ts @@ -0,0 +1,15 @@ +import type { Context } from "@hono/hono"; +import denojson from "$/deno.json" with { type: "json" }; + +/** + * Builds the metadata for an ASN from the context of an HTTP request. + * @param c the context of the HTTP request + * @returns ASN metadata for ASNs generated by an HTTP request + */ +export function createMetadata(c: Context): Record { + return { + client: "web", + path: c.req.path, + denojson, + }; +} diff --git a/lib/http/mod.ts b/lib/http/mod.ts new file mode 100644 index 0000000..9ac4e38 --- /dev/null +++ b/lib/http/mod.ts @@ -0,0 +1,48 @@ +/** + * @module + * Various APIs surrounding the HTTP server / Web Application. + * + * {@link httpApp} is the main HTTP server of the web application. + */ +import { Hono } from "@hono/hono"; +import { logger } from "@hono/hono/logger"; +import { serveStatic } from "@hono/hono/deno"; + +import { getFormatDescription } from "$common/mod.ts"; +import denojson from "$/deno.json" with { type: "json" }; + +import { svgRoutes } from "$http/routes/svg.ts"; +import { uiRoutes } from "$http/routes/ui.tsx"; +import { apiRoutes } from "$http/routes/api.ts"; +import { lookupRoutes } from "$http/routes/lookup.ts"; + +export * from "$http/lookup-url.ts"; +export * from "$http/barcode-svg.ts"; +export * from "$http/asn-metadata-from-context.ts"; + +/** + * The main HTTP server of the web application. + * This server is responsible for serving the web application and the API. + * + * @see + * + * @example + * ```ts + * Deno.serve(httpApp.fetch); + * ``` + */ +export const httpApp: Hono = new Hono(); + +httpApp.use(logger()); +httpApp.get( + "/about", + (c) => c.text(`${denojson.name} v${denojson.version} is running!`), +); +httpApp.get("/format", (c) => c.text(getFormatDescription())); + +httpApp.get("/static/*", serveStatic({ root: "." })); + +httpApp.route("/api", apiRoutes); +httpApp.route("/svg", svgRoutes); +httpApp.route("/", lookupRoutes); +httpApp.route("/", uiRoutes); diff --git a/lib/http/mod.tsx b/lib/http/mod.tsx deleted file mode 100644 index af246b4..0000000 --- a/lib/http/mod.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @module - * Various APIs surrounding the HTTP server / Web Application. - * - * {@link httpApp} is the main HTTP server of the web application. - */ -import { type Context, Hono } from "@hono/hono"; -import { logger } from "@hono/hono/logger"; -import { jsxRenderer } from "@hono/hono/jsx-renderer"; -import { serveStatic } from "@hono/hono/deno"; -import { validator } from "@hono/hono/validator"; - -import { - CONFIG, - generateASN, - getFormatDescription, - isValidASN, -} from "$common/mod.ts"; -import denojson from "$/deno.json" with { type: "json" }; - -import { Wrapper } from "$http/ui/wrapper.tsx"; -import { ASNPage } from "./ui/asn.tsx"; -import { createBarcodeSVG } from "$http/barcode-svg.ts"; -import { z } from "@collinhacks/zod"; -import { getLookupURL } from "$http/lookup-url.ts"; -import { IndexPage } from "$http/ui/index.tsx"; - -export * from "$http/lookup-url.ts"; -export * from "$http/barcode-svg.ts"; - -/** - * The main HTTP server of the web application. - * This server is responsible for serving the web application and the API. - * - * @see - * - * @example - * ```ts - * Deno.serve(httpApp.fetch); - * ``` - */ -export const httpApp: Hono = new Hono(); - -httpApp.use(logger()); -httpApp.get( - "/about", - (c) => c.text(`${denojson.name} v${denojson.version} is running!`), -); -httpApp.get("/format", (c) => c.text(getFormatDescription())); -httpApp.get("/json", async (c) => c.json(await generateASN())); - -httpApp.post( - "/lookup", - validator("form", (value, c) => { - const parsed = z.object({ - asn: z.string({ coerce: true }).min(1).regex(/^\d+$/), - }).safeParse(value); - - if (!parsed.success) { - return c.text("Invalid ASN. " + parsed.error.message, 400); - } - - return parsed.data; - }), - (c) => { - const asn = CONFIG.ASN_PREFIX + c.req.valid("form").asn; - return c.redirect("/go/" + asn); - }, -); - -httpApp.get( - "/go/:asn", - validator("param", (value, c) => { - if (!value || !isValidASN(value.asn)) { - return c.text("Invalid ASN", 400); - } - return value; - }), - (c) => { - const asn = c.req.valid("param").asn; - - if (!CONFIG.ASN_LOOKUP_URL) { - return c.text("ASN Lookup is disabled", 400); - } - - return c.redirect(getLookupURL(asn)); - }, -); - -httpApp.get("/static/*", serveStatic({ root: "." })); - -httpApp.get("/svg/:asn", (c) => { - const requestedASN = c.req.param("asn"); - - if (!requestedASN) { - return c.text("No ASN provided", 400); - } - - if (!isValidASN(requestedASN)) { - return c.text("Invalid ASN provided", 400); - } - - const barcode = createBarcodeSVG(requestedASN, !!c.req.query("embed")); - - return c.body(barcode ?? "", 200, { - "Cache-Control": "no-cache", - "Content-Type": "image/svg+xml", - }); -}); - -httpApp.get("/svg", async (c) => { - const barcode = createBarcodeSVG( - (await generateASN(createMetadata(c))).asn, - !!c.req.query("embed"), - ); - return c.body(barcode ?? "", 200, { - "Cache-Control": "no-cache", - "Content-Type": "image/svg+xml", - }); -}); - -httpApp.use("*", jsxRenderer(Wrapper)); - -httpApp.use( - "/", - async (c) => - await c.render( - , - ), -); - -httpApp.get( - "/asn", - validator("query", (value, c) => { - const res = z.object({ - namespace: z.number({ coerce: true }).optional().refine((v) => { - if (v === undefined) return true; - if ( - CONFIG.ADDITIONAL_MANAGED_NAMESPACES.find((n) => n.namespace === v) - ) { - return true; - } - }, { - message: - "Unregistered namespace. Please add it to the configuration's `ADDITIONAL_MANAGED_NAMESPACES` parameter.", - }), - }).safeParse(value); - - if (!res.success) { - return c.text(res.error.message, 400); - } - return res.data; - }), - async (c) => { - const namespace = c.req.valid("query").namespace; - - return await c.render( - , - ); - }, -); - -function createMetadata(c: Context) { - return { - client: "web", - path: c.req.path, - denojson, - }; -} diff --git a/lib/http/routes/api.ts b/lib/http/routes/api.ts new file mode 100644 index 0000000..8e02d88 --- /dev/null +++ b/lib/http/routes/api.ts @@ -0,0 +1,25 @@ +import { Hono } from "@hono/hono"; + +import { generateASN } from "$common/mod.ts"; + +import { createMetadata } from "$http/mod.ts"; +import { optionalQueryNamespaceValidator } from "$http/validators/query/optional-namespace.ts"; + +export const apiRoutes = new Hono(); + +apiRoutes.get( + "/asn", + optionalQueryNamespaceValidator, + async (c) => + c.json( + await generateASN( + createMetadata(c), + c.req.valid("query").namespace, + ), + ), +); + +apiRoutes.get( + "/asn/:asn", + (c) => c.text("Not implemented yet", 501), // TODO: Implement this route +); diff --git a/lib/http/routes/lookup.ts b/lib/http/routes/lookup.ts new file mode 100644 index 0000000..ea17983 --- /dev/null +++ b/lib/http/routes/lookup.ts @@ -0,0 +1,47 @@ +import { Hono } from "@hono/hono"; +import { validator } from "@hono/hono/validator"; +import { z } from "@collinhacks/zod"; + +import { CONFIG, isValidASN } from "$common/mod.ts"; + +import { getLookupURL } from "$http/mod.ts"; + +export const lookupRoutes = new Hono(); + +lookupRoutes.post( + "/lookup", + validator("form", (value, c) => { + const parsed = z.object({ + asn: z.string({ coerce: true }).min(1).regex(/^\d+$/), + }).safeParse(value); + + if (!parsed.success) { + return c.text("Invalid ASN. " + parsed.error.message, 400); + } + + return parsed.data; + }), + (c) => { + const asn = CONFIG.ASN_PREFIX + c.req.valid("form").asn; + return c.redirect("/go/" + asn); + }, +); + +lookupRoutes.get( + "/go/:asn", + validator("param", (value, c) => { + if (!value || !isValidASN(value.asn)) { + return c.text("Invalid ASN", 400); + } + return value; + }), + (c) => { + const asn = c.req.valid("param").asn; + + if (!CONFIG.ASN_LOOKUP_URL) { + return c.text("ASN Lookup is disabled", 400); + } + + return c.redirect(getLookupURL(asn)); + }, +); diff --git a/lib/http/routes/svg.ts b/lib/http/routes/svg.ts new file mode 100644 index 0000000..57abf34 --- /dev/null +++ b/lib/http/routes/svg.ts @@ -0,0 +1,30 @@ +import { Hono } from "@hono/hono"; +import { generateASN } from "$common/mod.ts"; +import { createBarcodeSVG } from "$http/barcode-svg.ts"; +import { createMetadata } from "../mod.ts"; +import { paramASNValidator } from "$http/validators/param/asn.ts"; +import { optionalQueryNamespaceValidator } from "$http/validators/query/optional-namespace.ts"; + +export const svgRoutes = new Hono(); + +svgRoutes.get("/", optionalQueryNamespaceValidator, async (c) => { + const barcode = createBarcodeSVG( + (await generateASN(createMetadata(c), c.req.valid("query").namespace)).asn, + !!c.req.query("embed"), + ); + return c.body(barcode ?? "", 200, { + "Cache-Control": "no-cache", + "Content-Type": "image/svg+xml", + }); +}); + +svgRoutes.get("/:asn", paramASNValidator, (c) => { + const { asn } = c.req.valid("param"); + + const barcode = createBarcodeSVG(asn, !!c.req.query("embed")); + + return c.body(barcode ?? "", 200, { + "Cache-Control": "no-cache", + "Content-Type": "image/svg+xml", + }); +}); diff --git a/lib/http/routes/ui.tsx b/lib/http/routes/ui.tsx new file mode 100644 index 0000000..234fe93 --- /dev/null +++ b/lib/http/routes/ui.tsx @@ -0,0 +1,40 @@ +import { Hono } from "@hono/hono"; +import { jsxRenderer } from "@hono/hono/jsx-renderer"; + +import { CONFIG, generateASN } from "$common/mod.ts"; + +import { createMetadata } from "../mod.ts"; + +import { Wrapper } from "$http/ui/wrapper.tsx"; +import { IndexPage } from "$http/ui/index.tsx"; +import { ASNPage } from "$http/ui/asn.tsx"; +import { optionalQueryNamespaceValidator } from "../validators/query/optional-namespace.ts"; + +export const uiRoutes = new Hono(); + +uiRoutes.use("*", jsxRenderer(Wrapper)); + +uiRoutes.get( + "/", + async (c) => + await c.render( + , + ), +); + +uiRoutes.get( + "/asn", + optionalQueryNamespaceValidator, + async (c) => { + const namespace = c.req.valid("query").namespace; + + return await c.render( + , + ); + }, +); diff --git a/lib/http/validators/param/asn.ts b/lib/http/validators/param/asn.ts new file mode 100644 index 0000000..f48df41 --- /dev/null +++ b/lib/http/validators/param/asn.ts @@ -0,0 +1,16 @@ +import { validator } from "@hono/hono/validator"; +import { isValidASN } from "$common/mod.ts"; + +export const paramASNValidator = validator("param", (value, c) => { + const asn = value.asn; + + if (!asn) { + return c.text("No ASN provided", 400); + } + + if (!isValidASN(asn)) { + return c.text("Invalid ASN provided", 400); + } + + return { asn }; +}); diff --git a/lib/http/validators/query/optional-namespace.ts b/lib/http/validators/query/optional-namespace.ts new file mode 100644 index 0000000..d167fe0 --- /dev/null +++ b/lib/http/validators/query/optional-namespace.ts @@ -0,0 +1,21 @@ +import { validator } from "@hono/hono/validator"; +import { z } from "@collinhacks/zod"; +import { isManagedNamespace } from "$common/mod.ts"; + +export const optionalQueryNamespaceValidator = validator("query", (value, c) => { + const res = z.object({ + namespace: z.number({ coerce: true }).optional().refine((v) => { + if (v === undefined) return true; + return isManagedNamespace(v); + }, { + message: + "Unregistered namespace. Please add it to the configuration's `ADDITIONAL_MANAGED_NAMESPACES` parameter.", + }), + }).safeParse(value); + + if (!res.success) { + return c.text(res.error.message, 400); + } + + return res.data; +}); diff --git a/main.ts b/main.ts index 0c96075..2649546 100644 --- a/main.ts +++ b/main.ts @@ -44,7 +44,7 @@ import { runStats } from "$cli/stats.ts"; import { runBump } from "$cli/bump.ts"; export * from "$common/mod.ts"; -export * from "$http/mod.tsx"; +export * from "./lib/http/mod.ts"; export * from "$cli/mod.ts"; if (import.meta.main) {