Files
gerbeur/api/routes/ws.ts
khannurien 6c4f410d9b
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
v3: added a chatbox
2026-06-29 20:25:10 +00:00

314 lines
7.8 KiB
TypeScript

import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts";
import {
broadcastChatMessage,
broadcastChatMessageDeleted,
broadcastChatMessageUpdated,
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastVoteUpdate,
getOnlineUsers,
handleClientPong,
isUserChatOpen,
register,
unregister,
type WsClient,
} from "../services/ws-service.ts";
import {
createChatMessage,
deleteChatMessage,
updateChatMessage,
} from "../services/chat-service.ts";
import { notifyMentions } from "../services/notification-service.ts";
import { hasPermission } from "../lib/permissions.ts";
import {
castVote,
getUserVotes,
removeVote,
} from "../services/vote-service.ts";
import {
castCommentLike,
getUserCommentLikes,
removeCommentLike,
} from "../services/comment-like-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import {
APIException,
type ClientToServerMessage,
} from "../model/interfaces.ts";
const router = new Router();
function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGINS.includes(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();
const avatarMime = authPayload
? getUserById(authPayload.userId).avatarMime
: undefined;
const client: WsClient = {
socket,
userId: authPayload?.userId,
username: authPayload?.username,
role: authPayload?.role,
avatarMime,
};
// Use addEventListener — more reliable than onopen= with Deno.serve
socket.addEventListener("open", () => {
register(client);
broadcastPresence();
try {
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
const myCommentLikes = authPayload
? getUserCommentLikes(authPayload.userId)
: [];
const unreadNotificationCount = authPayload
? getUnreadCount(authPayload.userId)
: 0;
socket.send(JSON.stringify({
type: "welcome",
users: getOnlineUsers(),
myVotes,
myCommentLikes,
unreadNotificationCount,
}));
} catch (err) {
console.error("[ws] welcome send failed:", err);
}
});
socket.addEventListener("message", (event) => {
let msg: ClientToServerMessage;
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;
case "comment_like_cast":
handleCommentLike(client, msg.commentId, "cast");
break;
case "comment_like_remove":
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body);
break;
case "chat_focus":
client.chatOpen = msg.open;
break;
case "chat_edit":
handleChatEdit(client, msg.id, msg.body);
break;
case "chat_delete":
handleChatDelete(client, msg.id);
break;
}
});
socket.addEventListener("close", () => {
unregister(client);
broadcastPresence();
});
});
function handleVote(
client: WsClient,
dumpId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
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, dumpId, action }));
}
}
function handleCommentLike(
client: WsClient,
commentId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const newCount = action === "cast"
? castCommentLike(commentId, client.userId)
: removeCommentLike(commentId, client.userId);
socket.send(JSON.stringify({
type: "comment_like_ack",
commentId,
action,
likeCount: newCount,
}));
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
} catch (err) {
const message = err instanceof APIException ? err.message : "Like failed";
socket.send(JSON.stringify({ type: "error", message, commentId, action }));
}
}
function handleChatSend(client: WsClient, body: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const message = createChatMessage(client.userId, body);
// Broadcast to everyone including the sender, so the sender's own message
// simply arrives over the socket — no optimistic-send machinery needed.
broadcastChatMessage(message);
// Skip mention notifications for users who already have the chat open —
// they've seen the message live.
notifyMentions(
client.userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to send message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
function canModerateChat(client: WsClient): boolean {
return !!client.role && hasPermission(client.role, "chat:moderate");
}
function handleChatEdit(client: WsClient, id: string, body: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const message = updateChatMessage(
id,
body,
client.userId,
canModerateChat(client),
);
broadcastChatMessageUpdated(message);
// A newly added @mention in an edit should still notify (deduped per
// message+user), skipping anyone already reading the chat.
notifyMentions(
client.userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to edit message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
function handleChatDelete(client: WsClient, id: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
deleteChatMessage(id, canModerateChat(client));
broadcastChatMessageDeleted(id);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to delete message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
export default router;