Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
314 lines
7.8 KiB
TypeScript
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;
|