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 | |
| parent | 76c7e52172f3dc25ee109434157d4d982ad9d2bd (diff) | |
| download | coinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.tar.gz coinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.zip | |
migrate to hono
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/app.ts | 225 | ||||
| -rw-r--r-- | lib/auth.ts | 87 | ||||
| -rw-r--r-- | lib/lightning.ts | 4 | ||||
| -rw-r--r-- | lib/sockets.ts | 137 | ||||
| -rw-r--r-- | lib/upload.ts | 17 | ||||
| -rw-r--r-- | lib/utils.ts | 2 |
6 files changed, 227 insertions, 245 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; diff --git a/lib/auth.ts b/lib/auth.ts index c2eca6d4..30d5438b 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,58 +1,53 @@ import config from "$config"; import { fail, getUser } from "$lib/utils"; -import fastifyPassport from "@fastify/passport"; -import jwt from "passport-jwt"; +import jwt from "jsonwebtoken"; +import { getCookie } from "hono/cookie"; -export const admin = { - preValidation: fastifyPassport.authenticate( - "jwt", - { session: false }, - (req, res, err, user, info) => { - if (!user.admin) return res.code(401).send("unauthorized"); - req.user = user; - }, - ), +const extractToken = (c) => { + const authHeader = c.req.header("authorization"); + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); + return getCookie(c, "token") || null; }; -export const optional = { - preValidation: fastifyPassport.authenticate( - "jwt", - { session: false }, - (req, res, err, user, info) => { - req.user = user; - }, - ), -}; +const authenticate = async (c) => { + const token = extractToken(c); + if (!token) return null; -export const auth = { - preValidation: fastifyPassport.authenticate("jwt", { - authInfo: false, - session: false, - }), -}; + try { + const payload = jwt.verify(token, config.jwt); + let { id } = payload as any; + const url = c.req.path; + const method = c.req.method; -export const requirePin = async ({ body, user }) => { - if (!user || (user.pin && user.pin !== body.pin)) fail("Invalid pin"); + const wl = { GET: ["/invoice", "/payments"], POST: ["/invoice"] }; + if (id.endsWith("-ro") && wl[method]?.some((p) => url.startsWith(p))) id = id.slice(0, -3); + + return await getUser(id); + } catch { + return null; + } }; -export const jwtStrategy = new jwt.Strategy( - { - jwtFromRequest: jwt.ExtractJwt.fromExtractors([ - jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), - (req) => (req.cookies ? req.cookies.token : null), - ]), - secretOrKey: config.jwt, - passReqToCallback: true, - }, - async (req, payload, next) => { - const { originalUrl: u, method: m } = req; +export const auth = async (c, next) => { + const user = await authenticate(c); + if (!user) return c.json("unauthorized", 401); + c.set("user", user); + await next(); +}; - let { id } = payload; - const wl = { GET: ["/invoice", "/payments"], POST: ["/invoice"] }; +export const optional = async (c, next) => { + const user = await authenticate(c); + if (user) c.set("user", user); + await next(); +}; - if (id.endsWith("-ro") && wl[m].some((p) => u.startsWith(p))) id = id.slice(0, -3); +export const admin = async (c, next) => { + const user = await authenticate(c); + if (!user?.admin) return c.json("unauthorized", 401); + c.set("user", user); + await next(); +}; - const user = await getUser(id); - next(null, user); - }, -); +export const requirePin = async ({ body, user }) => { + if (!user || (user.pin && user.pin !== body.pin)) fail("Invalid pin"); +}; diff --git a/lib/lightning.ts b/lib/lightning.ts index a259b601..3b1882d4 100644 --- a/lib/lightning.ts +++ b/lib/lightning.ts @@ -164,7 +164,7 @@ export async function replay(index) { } } -export const fixBolt12 = async (_, res) => { +export const fixBolt12 = async (c) => { for await (const k of scan("payment:*")) { const p = await g(k); if (p.type === "bolt12") { @@ -182,5 +182,5 @@ export const fixBolt12 = async (_, res) => { } } - res.send({}); + return c.json({}); }; diff --git a/lib/sockets.ts b/lib/sockets.ts index 88817728..51ba15fa 100644 --- a/lib/sockets.ts +++ b/lib/sockets.ts @@ -81,88 +81,77 @@ const track = async (ws, token) => { ws.user = user; }; -Bun.serve({ - hostname: "0.0.0.0", - port: 3120, - fetch(req, server) { - // upgrade the request to a WebSocket - if (server.upgrade(req)) { - return; // do not return a Response - } - return new Response("Upgrade failed", { status: 500 }); - }, - websocket: { - async message(ws: any, message: string) { - let type; - let data; +export const websocket = { + async message(ws: any, message: string) { + let type; + let data; - try { - ({ type, data } = JSON.parse(message)); - } catch (e) { - err("coudn't parse socket message"); - } + try { + ({ type, data } = JSON.parse(message)); + } catch (e) { + err("coudn't parse socket message"); + } - switch (type) { - case "heartbeat": - ws.beats = 0; - try { - if (data) await track(ws, data); - ws.send(JSON.stringify({ type: "id", data: ws.id })); - } catch (e) { - // err("Failed to send heartbeat", e.message); - } - break; + switch (type) { + case "heartbeat": + ws.beats = 0; + try { + if (data) await track(ws, data); + ws.send(JSON.stringify({ type: "id", data: ws.id })); + } catch (e) { + // err("Failed to send heartbeat", e.message); + } + break; - case "login": - try { - if (!data || data === "null" || !jwt.decode(data)) - return ws.close(code, `bad token ${data}`); + case "login": + try { + if (!data || data === "null" || !jwt.decode(data)) + return ws.close(code, `bad token ${data}`); - await track(ws, data); - } catch (e) { - if (e.message.includes("not found")) ws.send(JSON.stringify({ type: "logout" })); - setTimeout(() => ws?.close(code, `closing due to error ${e.message}`), 1000); - } - break; - case "token": - if (!ws.user) return; - ws.token = data; - break; - case "lnurl": - store.sessions[data] = ws; - break; - case "subscribe": - subscriptions.push({ invoice: data, ws }); - break; - default: - // warn("received socket message of unknown type", type, data); - } - }, - open(ws: any) { - const id = v4(); - ws.id = id; - ws.beats = 0; - ws.send(JSON.stringify({ type: "connected", data: id })); - all[id] = ws; - }, - close(ws: any) { - const { id } = ws; - try { - const uid = users[id]; - if (store.sockets[uid]?.[id]) { - delete store.sockets[uid][id]; + await track(ws, data); + } catch (e) { + if (e.message.includes("not found")) ws.send(JSON.stringify({ type: "logout" })); + setTimeout(() => ws?.close(code, `closing due to error ${e.message}`), 1000); } + break; + case "token": + if (!ws.user) return; + ws.token = data; + break; + case "lnurl": + store.sessions[data] = ws; + break; + case "subscribe": + subscriptions.push({ invoice: data, ws }); + break; + default: + // warn("received socket message of unknown type", type, data); + } + }, + open(ws: any) { + const id = v4(); + ws.id = id; + ws.beats = 0; + ws.send(JSON.stringify({ type: "connected", data: id })); + all[id] = ws; + }, + close(ws: any) { + const { id } = ws; + try { + const uid = users[id]; + if (store.sockets[uid]?.[id]) { + delete store.sockets[uid][id]; + } - let i; - ~(i = subscriptions.findIndex((s) => s.ws.id === id)) && subscriptions.splice(i, 1); + let i; + ~(i = subscriptions.findIndex((s) => s.ws.id === id)) && subscriptions.splice(i, 1); - if (all[id]) delete all[id]; - } catch (e) { - err("problem closing socket", e.message); - } - }, + if (all[id]) delete all[id]; + } catch (e) { + err("problem closing socket", e.message); + } }, -}); +}; export const sendHeartbeat = () => { for (const uid in store.sockets) { diff --git a/lib/upload.ts b/lib/upload.ts index 2ed5d905..900d49dd 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -5,14 +5,15 @@ import { bail, fail } from "$lib/utils"; import { fileTypeFromBuffer } from "file-type"; import sharp from "sharp"; -export default async (req, res) => { +export default async (c) => { try { - const { - params: { type }, - } = req; + const type = c.req.param("type"); - const data = await req.file(); - let buf = await data.toBuffer(); + const body = await c.req.parseBody(); + const file = body.file || Object.values(body).find((v) => v instanceof File); + if (!file) fail("no file uploaded"); + + let buf = Buffer.from(await (file as File).arrayBuffer()); const [format, ext] = (await fileTypeFromBuffer(buf)).mime.split("/"); @@ -26,9 +27,9 @@ export default async (req, res) => { const filePath = `/home/bun/app/data/uploads/${hash}.webp`; writeFileSync(filePath, buf); - res.send({ hash }); + return c.json({ hash }); } catch (e) { err("problem uploading", e.message); - bail(res, e.message); + return bail(c, e.message); } }; diff --git a/lib/utils.ts b/lib/utils.ts index 7a3d4c13..6f032cc7 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,7 +20,7 @@ export const wait = async (f, s = 300, n = 50) => { export const prod = process.env.NODE_ENV === "production"; -export const bail = (res, msg) => res.code(500).send(msg); +export const bail = (c, msg) => c.json(msg, 500); export const SATS = 100000000; export const sats = (n) => Math.round(n * SATS); |
