All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
import { Router } from "@oak/oak";
|
|
import { ALLOWED_ORIGINS } from "../config.ts";
|
|
import { authenticate } from "../lib/auth.ts";
|
|
import {
|
|
broadcastChatMessage,
|
|
broadcastChatMessageDeleted,
|
|
broadcastChatMessageUpdated,
|
|
broadcastCommentLikeUpdate,
|
|
broadcastPresence,
|
|
broadcastTyping,
|
|
broadcastVoteUpdate,
|
|
getOnlineUsers,
|
|
handleClientPong,
|
|
isUserChatOpen,
|
|
type RateLimit,
|
|
register,
|
|
sendToUser,
|
|
setClientTyping,
|
|
takeToken,
|
|
unregister,
|
|
type WsClient,
|
|
} from "../services/ws-service.ts";
|
|
import {
|
|
createChatMessage,
|
|
deleteChatMessage,
|
|
getChatReadState,
|
|
markChatRead,
|
|
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,
|
|
type ServerToClientMessage,
|
|
type User,
|
|
} 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;
|
|
}
|
|
|
|
// authenticate() refuses a token whose user is gone or which predates a
|
|
// password change, and reads the live role from the DB. Such a socket is
|
|
// simply anonymous; the client's next API call gets the 401.
|
|
const token = ctx.request.url.searchParams.get("token");
|
|
const authPayload = token ? await authenticate(token) : null;
|
|
let user: User | undefined;
|
|
try {
|
|
user = authPayload ? getUserById(authPayload.userId) : undefined;
|
|
} catch {
|
|
user = undefined;
|
|
}
|
|
|
|
const socket = ctx.upgrade();
|
|
|
|
const client: WsClient = {
|
|
socket,
|
|
userId: user?.id,
|
|
username: user?.username,
|
|
role: user?.role,
|
|
avatarMime: user?.avatarMime,
|
|
};
|
|
|
|
// Use addEventListener — more reliable than onopen= with Deno.serve
|
|
socket.addEventListener("open", () => {
|
|
register(client);
|
|
broadcastPresence();
|
|
|
|
try {
|
|
const myVotes = user ? getUserVotes(user.id) : [];
|
|
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
|
|
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
|
|
const chatRead = user ? getChatReadState(user.id) : undefined;
|
|
socket.send(JSON.stringify({
|
|
type: "welcome",
|
|
users: getOnlineUsers(),
|
|
myVotes,
|
|
myCommentLikes,
|
|
unreadNotificationCount,
|
|
chatReadAt: chatRead?.readAt,
|
|
unreadChatCount: chatRead?.unread ?? 0,
|
|
}));
|
|
} catch (err) {
|
|
console.error("[ws] welcome send failed:", err);
|
|
}
|
|
});
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
// An exception escaping a WebSocket listener is uncaught in Deno and ends
|
|
// the process, so nothing a client sends may throw out of here.
|
|
try {
|
|
handleMessage(client, event.data);
|
|
} catch (err) {
|
|
console.error("[ws] message handler failed:", err);
|
|
}
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
unregister(client);
|
|
broadcastPresence();
|
|
// If they were mid-compose, drop them from the typing set right away.
|
|
if (client.typingAt) broadcastTyping();
|
|
});
|
|
});
|
|
|
|
function handleMessage(client: WsClient, data: unknown): void {
|
|
if (typeof data !== "string") return;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch {
|
|
return;
|
|
}
|
|
// Anything but an object with a string `type` (`null`, a number, an array)
|
|
// is ignored rather than read from.
|
|
if (
|
|
typeof parsed !== "object" || parsed === null ||
|
|
typeof (parsed as { type?: unknown }).type !== "string"
|
|
) return;
|
|
const msg = parsed as ClientToServerMessage;
|
|
const { socket } = client;
|
|
|
|
switch (msg.type) {
|
|
case "ping":
|
|
send(socket, { 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, msg.replyToId, msg.nonce);
|
|
break;
|
|
case "chat_typing":
|
|
// Typing is cosmetic: past the limit it's just dropped.
|
|
if (
|
|
client.userId &&
|
|
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
|
) {
|
|
setClientTyping(client, msg.typing === true);
|
|
}
|
|
break;
|
|
case "chat_focus":
|
|
client.chatOpen = msg.open === true;
|
|
break;
|
|
case "chat_edit":
|
|
handleChatEdit(client, msg.id, msg.body);
|
|
break;
|
|
case "chat_delete":
|
|
handleChatDelete(client, msg.id);
|
|
break;
|
|
case "chat_read":
|
|
handleChatRead(client, msg.at);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Sends only while the socket is open: `send` on a closing socket throws.
|
|
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
|
}
|
|
|
|
function handleVote(
|
|
client: WsClient,
|
|
dumpId: unknown,
|
|
action: "cast" | "remove",
|
|
): void {
|
|
const { socket } = client;
|
|
|
|
if (!client.userId) {
|
|
send(socket, { type: "error", message: "Authentication required" });
|
|
return;
|
|
}
|
|
if (typeof dumpId !== "string") return;
|
|
|
|
try {
|
|
const { voteCount, isPrivate } = action === "cast"
|
|
? castVote(dumpId, client.userId)
|
|
: removeVote(dumpId, client.userId);
|
|
|
|
send(socket, {
|
|
type: "vote_ack",
|
|
dumpId,
|
|
action,
|
|
voteCount,
|
|
});
|
|
|
|
// Only a private dump's owner can vote on it, so they're its whole
|
|
// audience.
|
|
broadcastVoteUpdate(
|
|
dumpId,
|
|
voteCount,
|
|
client.userId,
|
|
action,
|
|
isPrivate ? client.userId : undefined,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof APIException ? err.message : "Vote failed";
|
|
send(socket, { type: "error", message, dumpId, action });
|
|
}
|
|
}
|
|
|
|
function handleCommentLike(
|
|
client: WsClient,
|
|
commentId: unknown,
|
|
action: "cast" | "remove",
|
|
): void {
|
|
const { socket } = client;
|
|
|
|
if (!client.userId) {
|
|
send(socket, { type: "error", message: "Authentication required" });
|
|
return;
|
|
}
|
|
if (typeof commentId !== "string") return;
|
|
|
|
try {
|
|
const { likeCount, isPrivate } = action === "cast"
|
|
? castCommentLike(commentId, client.userId)
|
|
: removeCommentLike(commentId, client.userId);
|
|
|
|
send(socket, {
|
|
type: "comment_like_ack",
|
|
commentId,
|
|
action,
|
|
likeCount,
|
|
});
|
|
|
|
broadcastCommentLikeUpdate(
|
|
commentId,
|
|
likeCount,
|
|
client.userId,
|
|
action,
|
|
isPrivate ? client.userId : undefined,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof APIException ? err.message : "Like failed";
|
|
send(socket, { type: "error", message, commentId, action });
|
|
}
|
|
}
|
|
|
|
// Sending, editing and deleting share one bucket: a burst of 8, then one every
|
|
// 1.5s — room for a quick back-and-forth, not for a flood.
|
|
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
|
|
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
|
// Read markers are cheap for the client to send often; past this they're
|
|
// dropped, and the next one catches up.
|
|
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
|
|
|
function sendChatError(
|
|
client: WsClient,
|
|
message: string,
|
|
nonce?: string,
|
|
): void {
|
|
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
|
}
|
|
|
|
// Validates the common preconditions of a chat write: signed in and under the
|
|
// rate limit. Returns the user id, or undefined after reporting why not.
|
|
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
|
if (!client.userId) {
|
|
sendChatError(client, "Authentication required", nonce);
|
|
return undefined;
|
|
}
|
|
if (!takeToken(`chat:${client.userId}`, CHAT_WRITE_LIMIT)) {
|
|
sendChatError(
|
|
client,
|
|
"You're sending messages too fast — wait a moment",
|
|
nonce,
|
|
);
|
|
return undefined;
|
|
}
|
|
return client.userId;
|
|
}
|
|
|
|
function optionalString(value: unknown, maxLength: number): string | undefined {
|
|
return typeof value === "string" && value.length <= maxLength
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function handleChatSend(
|
|
client: WsClient,
|
|
body: unknown,
|
|
replyToId: unknown,
|
|
rawNonce: unknown,
|
|
): void {
|
|
const nonce = optionalString(rawNonce, 64);
|
|
const userId = chatWriter(client, nonce);
|
|
if (!userId) return;
|
|
if (typeof body !== "string") {
|
|
sendChatError(client, "Message cannot be empty", nonce);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = createChatMessage(
|
|
userId,
|
|
body,
|
|
optionalString(replyToId, 64),
|
|
);
|
|
// Sending a message ends composition — clear the typing flag before the
|
|
// message broadcast so the indicator doesn't linger next to it.
|
|
setClientTyping(client, false);
|
|
// Broadcast to everyone including the sender, whose pending copy the nonce
|
|
// retires.
|
|
broadcastChatMessage(message, nonce);
|
|
// Skip mention notifications for users who already have the chat open —
|
|
// they've seen the message live.
|
|
notifyMentions(
|
|
userId,
|
|
message.body,
|
|
"chat",
|
|
message.id,
|
|
"",
|
|
undefined,
|
|
isUserChatOpen,
|
|
);
|
|
} catch (err) {
|
|
sendChatError(
|
|
client,
|
|
err instanceof APIException ? err.message : "Failed to send message",
|
|
nonce,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Reads the role from the DB on every moderation check rather than trusting the
|
|
// one captured at connect: a demoted moderator loses the power right away, not
|
|
// whenever their socket next reconnects.
|
|
function canModerateChat(client: WsClient): boolean {
|
|
if (!client.userId) return false;
|
|
try {
|
|
client.role = getUserById(client.userId).role;
|
|
} catch {
|
|
client.role = undefined;
|
|
}
|
|
return !!client.role && hasPermission(client.role, "chat:moderate");
|
|
}
|
|
|
|
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
|
const userId = chatWriter(client);
|
|
if (!userId) return;
|
|
if (typeof id !== "string" || typeof body !== "string") {
|
|
sendChatError(client, "Invalid message");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = updateChatMessage(
|
|
id,
|
|
body,
|
|
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(
|
|
userId,
|
|
message.body,
|
|
"chat",
|
|
message.id,
|
|
"",
|
|
undefined,
|
|
isUserChatOpen,
|
|
);
|
|
} catch (err) {
|
|
sendChatError(
|
|
client,
|
|
err instanceof APIException ? err.message : "Failed to edit message",
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleChatDelete(client: WsClient, id: unknown): void {
|
|
const userId = chatWriter(client);
|
|
if (!userId) return;
|
|
if (typeof id !== "string") {
|
|
sendChatError(client, "Invalid message");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
deleteChatMessage(id, userId, canModerateChat(client));
|
|
broadcastChatMessageDeleted(id);
|
|
} catch (err) {
|
|
sendChatError(
|
|
client,
|
|
err instanceof APIException ? err.message : "Failed to delete message",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Moves the user's read marker and tells all their sockets, so every tab and
|
|
// device drops its badge together.
|
|
function handleChatRead(client: WsClient, at: unknown): void {
|
|
if (!client.userId || typeof at !== "string") return;
|
|
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
|
|
try {
|
|
const state = markChatRead(client.userId, at);
|
|
sendToUser(client.userId, {
|
|
type: "chat_read_update",
|
|
readAt: state.readAt,
|
|
unread: state.unread,
|
|
});
|
|
} catch {
|
|
// A malformed marker changes nothing; there's nothing to report.
|
|
}
|
|
}
|
|
|
|
export default router;
|