diff options
| author | Adam Soltys <[email protected]> | 2026-02-28 14:14:30 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-28 14:14:30 -0800 |
| commit | a1632ae402d11fdd7ffc360de97c55e0b4fad677 (patch) | |
| tree | aad5a1ea73f95dcd055c1d0b4ebf2708c36b4e01 /lib/app.ts | |
| parent | 76c7e52172f3dc25ee109434157d4d982ad9d2bd (diff) | |
| download | coinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.tar.gz coinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.zip | |
migrate to hono
Diffstat (limited to 'lib/app.ts')
| -rw-r--r-- | lib/app.ts | 225 |
1 files changed, 111 insertions, 114 deletions
@@ -1,34 +1,88 @@ -import config from "$config"; -import cors from "@fastify/cors"; -import fastifyProxy from "@fastify/http-proxy"; -import fastifyMultipart from "@fastify/multipart"; -import fastifyPassport from "@fastify/passport"; -import fastifyRateLimit from "@fastify/rate-limit"; -import fastifySecureSession from "@fastify/secure-session"; -import fastifyStatic from "@fastify/static"; -import fastify from "fastify"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { serveStatic } from "hono/bun"; import pino from "pino"; -import * as path from "path"; +const app = new Hono(); -import { jwtStrategy } from "$lib/auth"; +const reqLogger = pino(pino.destination("req")); +const resLogger = pino(pino.destination("res")); + +// CORS +app.use( + "*", + cors({ + origin: (origin) => origin || "*", + credentials: true, + }), +); + +// Static files +app.use("/public/*", serveStatic({ root: "/home/bun/app/data/uploads", rewriteRequestPath: (p) => p.replace("/public", "") })); + +// Rate limiting +const rateLimits = new Map<string, { count: number; reset: number }>(); +const strictLimits = new Map<string, { count: number; reset: number }>(); -const app = fastify({ - logger: true, - disableRequestLogging: true, - routerOptions: { maxParamLength: 500 }, +app.use("*", async (c, next) => { + const url = c.req.path; + + // Skip rate limiting for public assets + if (url.includes("public")) return next(); + + const ip = (c.req.header("cf-connecting-ip") as string) || c.env?.ip || "unknown"; + const ua = c.req.header("user-agent") || "unknown-ua"; + const rateLimitBy = c.req.header("rate-limit-by"); + const key = rateLimitBy === "ua" ? ua : ip; + const now = Date.now(); + + // General rate limit: 2000 req / 2s + const gen = rateLimits.get(key); + if (gen && now < gen.reset) { + gen.count++; + if (gen.count > 2000) { + return c.json( + { statusCode: 429, error: "Too Many Requests", message: "Rate limit exceeded, retry in 2 seconds" }, + 429, + ); + } + } else { + rateLimits.set(key, { count: 1, reset: now + 2000 }); + } + + // Strict rate limit for /login and /send: 10 req / 10s + const isStrict = url.includes("/login") || url.includes("/send"); + if (isStrict) { + const strictKey = `strict:${ua}`; + const s = strictLimits.get(strictKey); + if (s && now < s.reset) { + s.count++; + if (s.count > 10) { + return c.json( + { statusCode: 429, error: "Too Many Requests", message: "Rate limit exceeded" }, + 429, + ); + } + } else { + strictLimits.set(strictKey, { count: 1, reset: now + 10000 }); + } + } + + return next(); }); -const reqLogger = pino(pino.destination("req")); -const resLogger = pino(pino.destination("res")); +// Clean up rate limit maps periodically +setInterval(() => { + const now = Date.now(); + for (const [k, v] of rateLimits) if (now >= v.reset) rateLimits.delete(k); + for (const [k, v] of strictLimits) if (now >= v.reset) strictLimits.delete(k); +}, 5000); -// app.addHook("onRequest", async (req) => { -// reqLogger.info({ url: req.raw.url, id: req.id }); -// }); -// +// Request logging +app.use("*", async (c, next) => { + const start = Date.now(); + const url = c.req.path; -app.addHook("preHandler", async (req) => { - const url = req.raw.url; const ignore = [ "/ws", "/me", @@ -42,28 +96,36 @@ app.addHook("preHandler", async (req) => { "/accounts", "/contacts", ]; - if (ignore.some((path) => url.startsWith(path))) return; - if (req.method === "GET" && url.startsWith("/users")) return; - const xff = req.headers["x-forwarded-for"]; - const forwardedIp = Array.isArray(xff) ? xff[0] : xff?.split(",")[0]?.trim(); - const ip = req.headers["cf-connecting-ip"] || forwardedIp || req.ip; + const shouldLog = !ignore.some((path) => url.startsWith(path)) && + !(c.req.method === "GET" && url.startsWith("/users")); - reqLogger.info({ - method: req.method, - url, - ip, - headers: req.headers, - query: req.query, - body: req.body, - user: (req.user as any)?.username, - id: req.id, - }); -}); + if (shouldLog) { + const xff = c.req.header("x-forwarded-for"); + const forwardedIp = xff?.split(",")[0]?.trim(); + const ip = c.req.header("cf-connecting-ip") || forwardedIp || c.env?.ip || "unknown"; -app.addHook("onResponse", async (req, reply) => { - const rawCookies = req.raw.headers.cookie || ""; + let body; + // Only parse body for non-GET requests + if (c.req.method !== "GET" && c.req.method !== "HEAD") { + try { + body = await c.req.raw.clone().json(); + } catch {} + } + + reqLogger.info({ + method: c.req.method, + url, + ip, + query: c.req.query(), + body, + user: c.get("user")?.username, + }); + } + await next(); + + const rawCookies = c.req.header("cookie") || ""; const cookies: any = rawCookies.split(";").reduce((acc, cookie) => { const [key, value] = cookie.split("=").map((s) => s.trim()); if (key && value) acc[key] = value; @@ -71,84 +133,19 @@ app.addHook("onResponse", async (req, reply) => { }, {}); resLogger.info({ - id: req.id, - url: req.raw.url, - statusCode: reply.raw.statusCode, - durationMs: reply.elapsedTime, + url, + statusCode: c.res.status, + durationMs: Date.now() - start, username: cookies.username, }); }); -app.register(fastifyRateLimit, { - allowList: (req) => req.raw.url?.includes("public"), - max: 2000, - timeWindow: 2000, - keyGenerator: (req) => { - const ip = (req.headers["cf-connecting-ip"] as string) || req.ip; - const ua = req.headers["user-agent"] || "unknown-ua"; - return req.headers["rate-limit-by"] === "ua" ? ua : ip; - }, - errorResponseBuilder: () => { - return { - statusCode: 429, - error: "Too Many Requests", - message: "Rate limit exceeded, retry in 2 seconds", - }; - }, -}); - -app.register(fastifyRateLimit, { - allowList: (req) => { - const url = req.raw.url || ""; - const matches = url.includes("/login") || url.includes("/send"); - return !matches; - }, - max: 10, - timeWindow: 10000, - keyGenerator: (req) => (req.headers["user-agent"] as string) || "unknown-ua", - errorResponseBuilder: () => ({ - statusCode: 429, - error: "Too Many Requests", - message: "Rate limit exceeded", - }), -}); - -app.register(fastifyProxy, { - upstream: "http://localhost:3120", - prefix: "/ws", - rewritePrefix: "/ws", - websocket: true, -}); - -app.register(fastifySecureSession, { - key: Buffer.from(config.jwt, "hex"), -}); - -app.register(fastifyPassport.initialize()); -app.register(fastifyPassport.secureSession()); - -fastifyPassport.use("jwt", jwtStrategy); - -app.register(fastifyMultipart, { limits: { fileSize: 10 ** 7 } }); - -app.register(fastifyStatic, { - root: path.join("/home/bun/app/data/uploads"), - prefix: "/public/", -}); - -await app.register(cors, { - origin: true, - credentials: true, -}); - -app.setErrorHandler((error, _, reply) => { - if (error instanceof fastify.errorCodes.FST_ERR_BAD_STATUS_CODE) { - reply.status(500).send({ ok: false }); - } else { - reply.send(error); - } +// Error handler +app.onError((err, c) => { + return c.json({ ok: false }, 500); }); -app.setNotFoundHandler((_, reply) => reply.code(404).type("text/html").send("Not Found")); +// Not found handler +app.notFound((c) => c.text("Not Found", 404)); export default app; |
