aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-28 14:14:30 -0800
committerAdam Soltys <[email protected]>2026-02-28 14:14:30 -0800
commita1632ae402d11fdd7ffc360de97c55e0b4fad677 (patch)
treeaad5a1ea73f95dcd055c1d0b4ebf2708c36b4e01 /lib
parent76c7e52172f3dc25ee109434157d4d982ad9d2bd (diff)
downloadcoinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.tar.gz
coinos-server-a1632ae402d11fdd7ffc360de97c55e0b4fad677.zip
migrate to hono
Diffstat (limited to 'lib')
-rw-r--r--lib/app.ts225
-rw-r--r--lib/auth.ts87
-rw-r--r--lib/lightning.ts4
-rw-r--r--lib/sockets.ts137
-rw-r--r--lib/upload.ts17
-rw-r--r--lib/utils.ts2
6 files changed, 227 insertions, 245 deletions
diff --git a/lib/app.ts b/lib/app.ts
index da29d9fe..ec810288 100644
--- a/lib/app.ts
+++ b/lib/app.ts
@@ -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);