import { Router } from "@oak/oak"; import { verifyJWT } from "../lib/jwt.ts"; import { broadcastPresence, broadcastVoteUpdate, getOnlineUsers, handleClientPong, register, unregister, type WsClient, } from "../services/ws-service.ts"; import { castVote, getUserVotes, removeVote, } from "../services/vote-service.ts"; import { getUnreadCount } from "../services/notification-service.ts"; import { getUserById } from "../services/user-service.ts"; import { APIException } from "../model/interfaces.ts"; const router = new Router(); function isAllowedOrigin(origin: string): boolean { if (!origin) return false; return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); } router.get("/ws", async (ctx) => { const origin = ctx.request.headers.get("origin") ?? ""; if (!isAllowedOrigin(origin)) { ctx.response.status = 403; return; } if (!ctx.isUpgradable) { ctx.response.status = 426; return; } const token = ctx.request.url.searchParams.get("token"); const authPayload = token ? await verifyJWT(token) : null; const socket = ctx.upgrade(); let avatarMime: string | undefined; if (authPayload) { try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ } } const client: WsClient = { socket, userId: authPayload?.userId, username: authPayload?.username, avatarMime, }; // Use addEventListener — more reliable than onopen= with Deno.serve socket.addEventListener("open", () => { register(client); broadcastPresence(); try { const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; const unreadNotificationCount = authPayload ? getUnreadCount(authPayload.userId) : 0; socket.send(JSON.stringify({ type: "welcome", users: getOnlineUsers(), myVotes, unreadNotificationCount, })); } catch (err) { console.error("[ws] welcome send failed:", err); } }); socket.addEventListener("message", (event) => { let msg: { type: string; dumpId?: string }; try { msg = JSON.parse(event.data as string); } catch { return; } switch (msg.type) { case "ping": socket.send(JSON.stringify({ type: "pong" })); break; case "pong": handleClientPong(client); break; case "vote_cast": handleVote(client, msg.dumpId, "cast"); break; case "vote_remove": handleVote(client, msg.dumpId, "remove"); break; } }); socket.addEventListener("close", () => { unregister(client); broadcastPresence(); }); }); function handleVote( client: WsClient, dumpId: string | undefined, action: "cast" | "remove", ): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } if (!dumpId) { socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" })); return; } try { const newCount = action === "cast" ? castVote(dumpId, client.userId) : removeVote(dumpId, client.userId); socket.send(JSON.stringify({ type: "vote_ack", dumpId, action, success: true, voteCount: newCount, })); broadcastVoteUpdate(dumpId, newCount, client.userId, action); } catch (err) { const message = err instanceof APIException ? err.message : "Vote failed"; socket.send(JSON.stringify({ type: "error", message })); } } export default router;