diff --git a/src/index.ts b/src/index.ts index e8a30ab..fa6cc4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { cors } from "hono/cors"; import { compress } from "hono/compress"; import { csrf } from "hono/csrf"; import { etag } from "hono/etag"; +import { HTTPException } from "hono/http-exception"; import { secureHeaders } from "hono/secure-headers"; import { timeout } from "hono/timeout"; import { logger } from "hono/logger"; @@ -32,9 +33,8 @@ const limiter = rateLimiter({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 100, standardHeaders: "draft-6", - keyGenerator: (c) => c.req.header("x-app-service-id") ?? c.req.header("x-forwarded-for") ?? "", + keyGenerator: (c) => c.req.header("CF-Connecting-IP") ?? c.req.header("x-forwarded-for") ?? "", }); -app.use(limiter); app.use("*", async (c, next) => { c.set("service", null); @@ -45,6 +45,8 @@ app.use("*", async (c, next) => { app.use("/api/", timeout(5000)); app.route("/api/v2", v2Router); + +app.use(limiter); app.route("/docs", docsRouter); app.get( @@ -61,6 +63,16 @@ app.get("/", (c) => { return c.redirect("/docs"); }); +app.onError(function handleError(err, c) { + if (err instanceof HTTPException) { + const response = err.getResponse(); + return response; + } + + c.status(500); + return c.json({ success: false, message: "Unknown Internal Server Error" }); +}); + if (env.FREEZE_DB_WRITES) { console.warn("\n🚨 Database writes are currently frozen!!!\n"); } diff --git a/src/routers/v2/index.ts b/src/routers/v2/index.ts index a4d53ca..c22b3fb 100644 --- a/src/routers/v2/index.ts +++ b/src/routers/v2/index.ts @@ -1,12 +1,25 @@ import { Hono } from "hono"; +import { rateLimiter } from "hono-rate-limiter"; + +import type { ServerContext } from "@/types/hono"; import servicesRouter from "./services"; import logsRouter from "./logging"; -import type { ServerContext } from "@/types/hono"; - const app = new Hono(); +const limiter = rateLimiter({ + windowMs: 5 * 60 * 1000, // 5 minutes + limit: 100, + standardHeaders: "draft-6", + keyGenerator: (c) => { + return ( + c.req.header("x-app-service-id") ?? c.req.header("CF-Connecting-IP") ?? c.req.header("x-forwarded-for") ?? "" + ); + }, +}); +app.use(limiter); + app.route("/service", servicesRouter); app.route("/log", logsRouter); diff --git a/src/routers/v2/logging/index.ts b/src/routers/v2/logging/index.ts index 0317161..f46ffff 100644 --- a/src/routers/v2/logging/index.ts +++ b/src/routers/v2/logging/index.ts @@ -1,10 +1,11 @@ import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; import { and, eq, lt } from "drizzle-orm"; import { db } from "@/config/db"; import { env } from "@/config/env"; import { logs as logsTable } from "@/config/db/schema"; -import { parseSearchParams, v2_serviceValidation } from "@/utils/server-helpers"; +import { createV2ErrResponse, parseSearchParams, v2_serviceValidation } from "@/utils/server-helpers"; import { ENDPOINT_MESSAGES } from "@/utils/messages"; import { createDbId } from "@/utils/db"; import type { ServerContext } from "@/types/hono"; @@ -25,28 +26,31 @@ app.get("/", v2_serviceValidation, async (c) => { const searchResult = getLogsFiltersSchema.safeParse(searchQuery); if (!searchResult.success) { - c.status(400); - return c.json({ success: false, message: searchResult.error.message }); + throw new HTTPException(400, { res: createV2ErrResponse(searchResult.error.message) }); } const search = searchResult.data; const logLevels = search.level.filter((val) => val !== "all"); - const logs = await db.query.logs.findMany({ - limit: search.page_size, - offset: search.page_size * (search.page - 1), - orderBy: (fields, { asc, desc }) => (search.sort === "ASC" ? asc(fields.createdAt) : desc(fields.createdAt)), - where: (fields, { and, eq, inArray }) => - and( - ...[eq(fields.serviceId, serviceId)], - ...(search.environment ? [eq(fields.environment, search.environment)] : []), - ...(search.lookup ? [eq(fields.lookupFilterValue, search.lookup)] : []), - ...(logLevels.length > 0 ? [inArray(fields.level, logLevels)] : []), - ), - }); - - return c.json(getLogsOutputSchema.parse(logs)); + try { + const logs = await db.query.logs.findMany({ + limit: search.page_size, + offset: search.page_size * (search.page - 1), + orderBy: (fields, { asc, desc }) => (search.sort === "ASC" ? asc(fields.createdAt) : desc(fields.createdAt)), + where: (fields, { and, eq, inArray }) => + and( + ...[eq(fields.serviceId, serviceId)], + ...(search.environment ? [eq(fields.environment, search.environment)] : []), + ...(search.lookup ? [eq(fields.lookupFilterValue, search.lookup)] : []), + ...(logLevels.length > 0 ? [inArray(fields.level, logLevels)] : []), + ), + }); + + return c.json(getLogsOutputSchema.parse(logs)); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -66,40 +70,43 @@ app.post("/", v2_serviceValidation, async (c) => { const bodyResult = createLogSchema.safeParse(body); if (!bodyResult.success) { - c.status(400); - return c.json({ success: false, message: bodyResult.error.message }); + throw new HTTPException(400, { res: createV2ErrResponse(bodyResult.error.message) }); } const input = bodyResult.data; - const logId = createDbId("log"); - const log = await db - .insert(logsTable) - .values({ - id: logId, - serviceId, - action: input.action, - environment: input.environment, - ip: input.ip, - data: input.data || {}, - isPersisted: service.isPersisted, - lookupFilterValue: input.lookupFilterValue, - level: input.level, - }) - .returning({ - id: logsTable.id, - action: logsTable.id, - environment: logsTable.environment, - ip: logsTable.ip, - lookupFilterValue: logsTable.lookupFilterValue, - data: logsTable.data, - level: logsTable.level, - createdAt: logsTable.createdAt, - }) - .execute(); - - c.status(201); - return c.json(createLogOutputSchema.parse(log[0])); + try { + const logId = createDbId("log"); + const log = await db + .insert(logsTable) + .values({ + id: logId, + serviceId, + action: input.action, + environment: input.environment, + ip: input.ip, + data: input.data || {}, + isPersisted: service.isPersisted, + lookupFilterValue: input.lookupFilterValue, + level: input.level, + }) + .returning({ + id: logsTable.id, + action: logsTable.id, + environment: logsTable.environment, + ip: logsTable.ip, + lookupFilterValue: logsTable.lookupFilterValue, + data: logsTable.data, + level: logsTable.level, + createdAt: logsTable.createdAt, + }) + .execute(); + + c.status(201); + return c.json(createLogOutputSchema.parse(log[0])); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -121,28 +128,32 @@ app.delete("/purge", v2_serviceValidation, async (c) => { const date = new Date(); date.setMonth(date.getMonth() - countMonths); - await db - .delete(logsTable) - .where(and(eq(logsTable.isPersisted, false), lt(logsTable.createdAt, date.toISOString()))) - .execute(); - - const logId = createDbId("log"); - await db - .insert(logsTable) - .values({ - id: logId, - serviceId: service.id, - isPersisted: true, - action: "app-admin-clean-service-logs", - ip, - environment: "production", - lookupFilterValue: "app-admin-action", - data: { client: service.name, ip }, - level: "info", - }) - .execute(); - - return c.json({ success: true, message: `Successfully cleaned logs for the last ${countMonths} months` }); + try { + await db + .delete(logsTable) + .where(and(eq(logsTable.isPersisted, false), lt(logsTable.createdAt, date.toISOString()))) + .execute(); + + const logId = createDbId("log"); + await db + .insert(logsTable) + .values({ + id: logId, + serviceId: service.id, + isPersisted: true, + action: "app-admin-clean-service-logs", + ip, + environment: "production", + lookupFilterValue: "app-admin-action", + data: { client: service.name, ip }, + level: "info", + }) + .execute(); + + return c.json({ success: true, message: `Successfully cleaned logs for the last ${countMonths} months` }); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); export default app; diff --git a/src/routers/v2/services/index.ts b/src/routers/v2/services/index.ts index 2042c83..e219284 100644 --- a/src/routers/v2/services/index.ts +++ b/src/routers/v2/services/index.ts @@ -1,9 +1,17 @@ +import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; import { db } from "@/config/db"; -import { createDbId } from "@/utils/db"; -import { v2_serviceValidation, adminServiceValidation, parseSearchParams } from "@/utils/server-helpers"; import { services as servicesTable } from "@/config/db/schema"; +import { createDbId } from "@/utils/db"; +import { ENDPOINT_MESSAGES } from "@/utils/messages"; +import { + createV2ErrResponse, + v2_serviceValidation, + adminServiceValidation, + parseSearchParams, +} from "@/utils/server-helpers"; import type { ServerContext } from "@/types/hono"; import { @@ -13,8 +21,6 @@ import { getServiceOutputSchema, getServicesOutputSchema, } from "./schemas"; -import { ENDPOINT_MESSAGES } from "@/utils/messages"; -import { eq } from "drizzle-orm"; const app = new Hono(); @@ -27,19 +33,21 @@ app.get("/", v2_serviceValidation, adminServiceValidation, async (c) => { const searchResult = getServiceFiltersSchema.safeParse(searchQuery); if (!searchResult.success) { - c.status(400); - return c.json({ success: false, message: searchResult.error.message }); + throw new HTTPException(400, { res: createV2ErrResponse(searchResult.error.message) }); } const search = searchResult.data; - const services = await db.query.services.findMany({ - limit: search.page_size, - offset: search.page_size * (search.page - 1), - orderBy: (fields, { desc }) => desc(fields.createdAt), - }); - - return c.json(getServicesOutputSchema.parse(services)); + try { + const services = await db.query.services.findMany({ + limit: search.page_size, + offset: search.page_size * (search.page - 1), + orderBy: (fields, { desc }) => desc(fields.createdAt), + }); + return c.json(getServicesOutputSchema.parse(services)); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -51,33 +59,36 @@ app.post("/", v2_serviceValidation, adminServiceValidation, async (c) => { const bodyResult = createServiceInputSchema.safeParse(body); if (!bodyResult.success) { - c.status(400); - return c.json({ success: false, message: bodyResult.error.message }); + throw new HTTPException(400, { res: createV2ErrResponse(bodyResult.error.message) }); } const input = bodyResult.data; - const serviceId = createDbId("service"); - const service = await db - .insert(servicesTable) - .values({ - id: serviceId, - name: input.name, - isPersisted: input.isPersisted, - isAdmin: input.isAdmin, - isActive: true, - }) - .returning({ - id: servicesTable.id, - name: servicesTable.name, - isPersisted: servicesTable.isPersisted, - isAdmin: servicesTable.isAdmin, - isActive: servicesTable.isActive, - createdAt: servicesTable.createdAt, - }) - .execute(); - - return c.json(createServiceOutputSchema.parse(service[0])); + try { + const serviceId = createDbId("service"); + const service = await db + .insert(servicesTable) + .values({ + id: serviceId, + name: input.name, + isPersisted: input.isPersisted, + isAdmin: input.isAdmin, + isActive: true, + }) + .returning({ + id: servicesTable.id, + name: servicesTable.name, + isPersisted: servicesTable.isPersisted, + isAdmin: servicesTable.isAdmin, + isActive: servicesTable.isActive, + createdAt: servicesTable.createdAt, + }) + .execute(); + + return c.json(createServiceOutputSchema.parse(service[0])); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -87,16 +98,19 @@ app.post("/", v2_serviceValidation, adminServiceValidation, async (c) => { app.get("/:service_id", v2_serviceValidation, adminServiceValidation, async (c) => { const serviceId = c.req.param("service_id"); - const service = await db.query.services.findFirst({ - where: (fields, { and, eq }) => and(eq(fields.id, serviceId), eq(fields.isActive, true)), - }); + try { + const service = await db.query.services.findFirst({ + where: (fields, { and, eq }) => and(eq(fields.id, serviceId), eq(fields.isActive, true)), + }); - if (!service) { - c.status(404); - return c.json({ success: false, message: ENDPOINT_MESSAGES.ServiceNotFound }); - } + if (!service) { + throw new HTTPException(404, { res: createV2ErrResponse(ENDPOINT_MESSAGES.ServiceNotFound) }); + } - return c.json(getServiceOutputSchema.parse(service)); + return c.json(getServiceOutputSchema.parse(service)); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -107,15 +121,18 @@ app.delete("/:service_id", v2_serviceValidation, adminServiceValidation, async ( const reqServiceId = c.var.service!.id; const serviceId = c.req.param("service_id"); - if (reqServiceId.trim() === serviceId.trim()) { - c.status(400); - return c.json({ success: false, message: ENDPOINT_MESSAGES.ServiceCannotDisableSelf }); + if (reqServiceId === serviceId) { + throw new HTTPException(400, { res: createV2ErrResponse(ENDPOINT_MESSAGES.ServiceCannotDisableSelf) }); } - await db.update(servicesTable).set({ isActive: false }).where(eq(servicesTable.id, serviceId)).execute(); + try { + await db.update(servicesTable).set({ isActive: false }).where(eq(servicesTable.id, serviceId)).execute(); - c.status(200); - return c.json({ success: true, message: ENDPOINT_MESSAGES.ServiceDisabled }); + c.status(200); + return c.json({ success: true, message: ENDPOINT_MESSAGES.ServiceDisabled }); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); /** @@ -125,10 +142,14 @@ app.delete("/:service_id", v2_serviceValidation, adminServiceValidation, async ( app.post("/:service_id/enable", v2_serviceValidation, adminServiceValidation, async (c) => { const serviceId = c.req.param("service_id"); - await db.update(servicesTable).set({ isActive: true }).where(eq(servicesTable.id, serviceId)).execute(); + try { + await db.update(servicesTable).set({ isActive: true }).where(eq(servicesTable.id, serviceId)).execute(); - c.status(200); - return c.json({ success: true, message: ENDPOINT_MESSAGES.ServiceEnabled }); + c.status(200); + return c.json({ success: true, message: ENDPOINT_MESSAGES.ServiceEnabled }); + } catch (error) { + throw new HTTPException(500, { res: createV2ErrResponse(ENDPOINT_MESSAGES.InternalError) }); + } }); export default app; diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 04f48c9..4336824 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -11,4 +11,5 @@ export const ENDPOINT_MESSAGES = { ServiceDoesNotExistOrDoesNotHaveNecessaryRights: "Service does not exist or does not have necessary rights.", DBWritesFrozen: "Database writes are currently frozen.", ServiceCannotDisableSelf: "You cannot disable the service you are using.", + InternalError: "Internal error. Please try again later.", }; diff --git a/src/utils/server-helpers.ts b/src/utils/server-helpers.ts index aa28fd5..b6776f2 100644 --- a/src/utils/server-helpers.ts +++ b/src/utils/server-helpers.ts @@ -1,14 +1,15 @@ import { createFactory } from "hono/factory"; -import type { Context } from "hono"; +import { HTTPException } from "hono/http-exception"; import { db } from "@/config/db"; import { env } from "@/config/env"; +import type { Context } from "hono"; import type { ServerContext } from "@/types/hono"; import { ENDPOINT_MESSAGES } from "./messages"; /** - * Takes a URL and returns an object with the query string parameters, multiple of the same key will be an array + * Takes a string URL and returns an object with the query string parameters, multiple of the same key will be an array */ export function parseSearchParams(url: string): Record { const search = new URL(url).searchParams; @@ -30,7 +31,7 @@ export function parseSearchParams(url: string): Record(); +const honoFactory = createFactory(); /** * V2 implementation of the middleware to validate that a service request by the service ID in the "x-app-service-id" header */ -export const v2_serviceValidation = factory.createMiddleware(async (c, next) => { - const serviceId = getServiceId(c); +export const v2_serviceValidation = honoFactory.createMiddleware(async (c, next) => { + const serviceId = v2_getServiceId(c); if (!serviceId) { - c.status(401); - return c.json({ success: false, message: ENDPOINT_MESSAGES.ServiceIdHeaderNotProvided }); + throw new HTTPException(401, { + res: createV2ErrResponse( + JSON.stringify({ success: false, message: ENDPOINT_MESSAGES.ServiceIdHeaderNotProvided }), + ), + }); } const service = await getService(serviceId); if (!service) { - c.status(403); - return c.json({ success: false, message: ENDPOINT_MESSAGES.ServiceDoesNotExistOrDoesNotHaveNecessaryRights }); + throw new HTTPException(403, { + res: createV2ErrResponse( + JSON.stringify({ success: false, message: ENDPOINT_MESSAGES.ServiceDoesNotExistOrDoesNotHaveNecessaryRights }), + ), + }); } c.set("service", service); @@ -75,12 +82,15 @@ export const v2_serviceValidation = factory.createMiddleware(async (c, next) => /** * Middleware to validate that a service ID is provided and that the service exists and is an admin service */ -export const adminServiceValidation = factory.createMiddleware(async (c, next) => { +export const adminServiceValidation = honoFactory.createMiddleware(async (c, next) => { const service = c.var.service; - if (!service) { - c.status(403); - return c.json({ success: false, message: ENDPOINT_MESSAGES.ServiceDoesNotExistOrDoesNotHaveNecessaryRights }); + if (!service || !service.isAdmin) { + throw new HTTPException(403, { + res: createV2ErrResponse( + JSON.stringify({ success: false, message: ENDPOINT_MESSAGES.ServiceDoesNotExistOrDoesNotHaveNecessaryRights }), + ), + }); } await next(); @@ -93,3 +103,18 @@ export const adminServiceValidation = factory.createMiddleware(async (c, next) = export function getUserServerUrl(): string { return env.NODE_ENV === "production" ? env.SERVER_URI : `http://localhost:${env.PORT}`; } + +/** + * Helper function to create a response that matches the V2 error response format + * @param message Message to include in the response. + * @param headers Headers to include in the response. Defaults to an empty object with a "Content-Type" header set to "application/json" + */ +export function createV2ErrResponse(message: string, headers: Record = {}): Response { + const responseHeaders = new Headers(headers); + + if (!responseHeaders.get("Content-Type")) { + responseHeaders.set("Content-Type", "application/json"); + } + + return new Response(message, { headers }); +}