Merge pull request #1130 from J3vb/claude/v2-command-event-refactor

refactor: migrate WS handlers to V2 Command/Event architecture
This commit is contained in:
J3vb
2026-04-05 21:09:05 +02:00
committed by GitHub
36 changed files with 4413 additions and 890 deletions
+4 -8
View File
@@ -29,20 +29,16 @@ const ECDH_CURVE = "P-256";
// UTF-8 bytes of "owncord-voice-e2ee-v1"
const HKDF_SALT = new Uint8Array([
111, 119, 110, 99, 111, 114, 100, 45, 118, 111, 105, 99, 101, 45, 101, 50, 101, 101, 45, 118, 49,
]) as Uint8Array<ArrayBuffer>;
]);
// UTF-8 bytes of "room-key-wrap"
const HKDF_INFO = new Uint8Array([
114, 111, 111, 109, 45, 107, 101, 121, 45, 119, 114, 97, 112,
]) as Uint8Array<ArrayBuffer>;
const HKDF_INFO = new Uint8Array([114, 111, 111, 109, 45, 107, 101, 121, 45, 119, 114, 97, 112]);
const ROOM_KEY_BYTES = 32; // 256-bit AES key for LiveKit SFrame
// ── Key pair generation ─────────────────────────────────────────────────────
/** Generate an ephemeral ECDH P-256 keypair. */
export async function generateECDHKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: "ECDH", namedCurve: ECDH_CURVE }, true, [
"deriveBits",
]) as Promise<CryptoKeyPair>;
return crypto.subtle.generateKey({ name: "ECDH", namedCurve: ECDH_CURVE }, true, ["deriveBits"]);
}
/** Export a CryptoKey (public) to base64 for transmission. */
@@ -183,7 +179,7 @@ function base64ToUint8(base64: string): Uint8Array<ArrayBuffer> {
} catch {
throw new Error("E2EE: invalid base64 input");
}
const bytes = new Uint8Array(binary.length) as Uint8Array<ArrayBuffer>;
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
@@ -78,6 +78,7 @@ type PendingVoiceJoin = {
readonly url: string;
readonly channelId: number;
readonly directUrl?: string;
readonly isKeyHolder?: boolean;
};
// --- State machine ---
@@ -1061,7 +1062,7 @@ export class LiveKitSession {
log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err });
if (localRoom !== null) {
try {
localRoom.disconnect();
void localRoom.disconnect();
} catch {
/* ignore */
}
@@ -1101,7 +1102,7 @@ export class LiveKitSession {
if (this._state.type === "connecting") {
this.setState({
...this._state,
pendingJoin: { token, url, channelId, directUrl },
pendingJoin: { token, url, channelId, directUrl, isKeyHolder },
});
}
log.warn("handleVoiceToken: already connecting, queued latest join request", { channelId });
@@ -1122,6 +1123,7 @@ export class LiveKitSession {
url: pUrl,
channelId: pChannelId,
directUrl: pDirectUrl,
isKeyHolder: pIsKeyHolder,
} = pendingJoin;
const cur = this._state;
if (
@@ -1132,7 +1134,7 @@ export class LiveKitSession {
this.handleVoiceTokenRefresh(pToken);
} else {
// eslint-disable-next-line no-await-in-loop -- sequential drain of pending joins to avoid unbounded recursion
await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl);
await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl, pIsKeyHolder);
// If this attempt was itself superseded (another join arrived during the
// await), the loop will naturally pick it up via the updated pendingJoin.
}
@@ -1363,7 +1365,7 @@ export class LiveKitSession {
if (!this._isKeyHolder) return;
this._keyRotationTimer = setTimeout(() => {
this._keyRotationTimer = null;
this.rotateKeyPeriodically();
void this.rotateKeyPeriodically();
}, LiveKitSession.KEY_ROTATION_INTERVAL_MS);
log.debug("E2EE: key rotation timer started", {
intervalMs: LiveKitSession.KEY_ROTATION_INTERVAL_MS,
@@ -17,6 +17,9 @@ vi.mock("@lib/notifications", () => ({
}));
vi.mock("@lib/livekitSession", () => ({
handleVoiceToken: vi.fn(async () => {}),
handleParticipantLeft: vi.fn(async () => {}),
handleE2EEAnnounce: vi.fn(async () => {}),
handleE2EEOffer: vi.fn(async () => {}),
leaveVoice: vi.fn(),
cleanupAll: vi.fn(),
isVoiceConnected: vi.fn(() => false),
@@ -693,6 +696,7 @@ describe("WS Dispatcher", () => {
"wss://livekit.example.com",
3,
"wss://direct.example.com",
undefined,
);
});
@@ -1143,6 +1147,12 @@ describe("WS Dispatcher", () => {
expect(voiceLeaveSent).toBe(false);
});
it("unknown event type does not throw", () => {
expect(() => {
mock.dispatch("totally_unknown_server_event", { some: "data" });
}).not.toThrow();
});
it("cleanup removes all listeners", () => {
cleanup();
@@ -11,7 +11,7 @@ import {
} from "@lib/e2eeCrypto";
vi.mock("@lib/logger", () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
describe("e2eeCrypto", () => {
@@ -78,7 +78,7 @@ describe("KeybindsTab", () => {
// --- PTT key capture flow ---
it("shows 'Press any key...' when capture button is clicked", () => {
it("shows 'Press a supported key...' when capture button is clicked", () => {
const el = buildKeybindsTab(new AbortController().signal);
mockCaptureKeyPress.mockReturnValue(new Promise(() => {})); // never resolves
const pttBtn = el
@@ -87,7 +87,7 @@ describe("KeybindsTab", () => {
pttBtn.click();
expect(pttBtn.textContent).toBe("Press any key...");
expect(pttBtn.textContent).toBe("Press a supported key...");
expect(pttBtn.style.borderColor).toBe("var(--accent)");
expect(pttBtn.style.color).toBe("var(--accent)");
});
@@ -149,7 +149,7 @@ describe("KeybindsTab", () => {
.querySelector(".kbd") as HTMLButtonElement;
pttBtn.click();
expect(pttBtn.textContent).toBe("Press any key...");
expect(pttBtn.textContent).toBe("Press a supported key...");
// Second click should be ignored
pttBtn.click();
@@ -264,7 +264,7 @@ describe("KeybindsTab", () => {
expect(pttBtn.textContent).toBe("F2");
pttBtn.click();
expect(pttBtn.textContent).toBe("Press any key...");
expect(pttBtn.textContent).toBe("Press a supported key...");
await vi.waitFor(() => {
expect(pttBtn.textContent).toBe("F2");
@@ -58,6 +58,10 @@ vi.mock("livekit-client", () => ({
h1080fps30: { resolution: { width: 1920, height: 1080 } },
},
DisconnectReason: { CLIENT_INITIATED: 0 },
ExternalE2EEKeyProvider: vi.fn(() => ({
setKey: vi.fn(),
getKeys: vi.fn().mockReturnValue([]),
})),
createLocalVideoTrack: vi.fn(async () => ({
kind: "video",
mediaStreamTrack: new MediaStreamTrack(),
@@ -118,6 +122,24 @@ vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
const mockKeyPair = vi.hoisted(() => ({
publicKey: { type: "public" } as unknown as CryptoKey,
privateKey: { type: "private" } as unknown as CryptoKey,
}));
vi.mock("@lib/e2eeCrypto", () => ({
generateECDHKeyPair: vi.fn(async () => mockKeyPair),
exportPublicKey: vi.fn(async () => "mock-pub-key-base64"),
importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey),
generateRoomKey: vi.fn(() => new Uint8Array(32)),
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
unwrapRoomKey: vi.fn(async () => new Uint8Array(32)),
}));
// Stub Worker for E2EE web worker (not available in Node/vitest)
globalThis.Worker = vi.fn() as unknown as typeof Worker;
// Now import
import { parseUserId, LiveKitSession, getRoomForStats } from "../../src/lib/livekitSession";
import {
@@ -501,7 +523,7 @@ describe("LiveKitSession", () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(mockRoom.connect).toHaveBeenCalledWith("ws://localhost:7880", "test-token");
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
@@ -511,7 +533,7 @@ describe("LiveKitSession", () => {
session.setServerHost("example.com:443");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("test-token", "/livekit", 1);
await session.handleVoiceToken("test-token", "/livekit", 1, undefined, true);
expect(mockInvoke).toHaveBeenCalledWith("start_livekit_proxy", {
remoteHost: "example.com:443",
@@ -528,7 +550,7 @@ describe("LiveKitSession", () => {
const domErr = new DOMException("Permission denied", "NotAllowedError");
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(domErr);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith(
"Microphone permission denied — joined in listen-only mode",
@@ -544,7 +566,7 @@ describe("LiveKitSession", () => {
const domErr = new DOMException("No device", "NotFoundError");
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(domErr);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("No microphone found — joined in listen-only mode");
});
@@ -557,7 +579,7 @@ describe("LiveKitSession", () => {
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("unknown"));
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("Microphone unavailable — joined in listen-only mode");
});
@@ -577,6 +599,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
// Advance through all retry delays (3 retries x 2000ms each)
@@ -603,10 +626,18 @@ describe("LiveKitSession", () => {
"/livekit-one",
1,
"ws://localhost:7881",
true,
);
await Promise.resolve();
// Flush microtasks so E2EE async steps resolve before connect
await vi.advanceTimersByTimeAsync(0);
await session.handleVoiceToken("second-token", "/livekit-two", 2, "ws://localhost:7882");
await session.handleVoiceToken(
"second-token",
"/livekit-two",
2,
"ws://localhost:7882",
true,
);
expect(mockRoom.connect).toHaveBeenCalledTimes(1);
firstConnect.resolve(undefined);
@@ -711,7 +742,7 @@ describe("LiveKitSession", () => {
});
// Connect to create the room and register handlers
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(disconnectedHandler).toBeDefined();
// Inject fake manual tracks as if camera/screen were enabled
@@ -760,6 +791,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
await Promise.resolve(); // Let handleVoiceToken reach room.connect()
@@ -1004,7 +1036,7 @@ describe("LiveKitSession", () => {
// Set up a room via handleVoiceToken
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect((session as any)._state.type).toBe("connected");
const room = (session as any)._state.room;
@@ -1018,7 +1050,7 @@ describe("LiveKitSession", () => {
it("sets currentChannelId to null after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 5, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 5, "ws://localhost:7880", true);
expect((session as any)._state.channelId).toBe(5);
@@ -1030,7 +1062,7 @@ describe("LiveKitSession", () => {
it("sets latestToken to null after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("my-token", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("my-token", "/lk", 1, "ws://localhost:7880", true);
expect((session as any)._state.latestToken).toBe("my-token");
@@ -1042,7 +1074,7 @@ describe("LiveKitSession", () => {
it("sets lastUrl to null and lastDirectUrl to undefined after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
session.leaveVoice(false);
@@ -1098,7 +1130,7 @@ describe("LiveKitSession", () => {
beforeEach(async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
});
@@ -1126,7 +1158,7 @@ describe("LiveKitSession", () => {
beforeEach(async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
});
@@ -1179,7 +1211,7 @@ describe("LiveKitSession", () => {
it("enables mic and exits listen-only on success", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
await session.retryMicPermission();
@@ -1192,7 +1224,7 @@ describe("LiveKitSession", () => {
it("applies noise suppressor when enhancedNoiseSuppression pref is true", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
@@ -1216,7 +1248,7 @@ describe("LiveKitSession", () => {
session.setWsClient({ send: vi.fn() } as any);
const errorCb = vi.fn();
session.setOnError(errorCb);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(
@@ -1235,7 +1267,7 @@ describe("LiveKitSession", () => {
it("sets up audio pipeline on success", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
const setupSpy = vi.spyOn((session as any)._audioPipeline, "setupAudioPipeline");
@@ -1267,7 +1299,7 @@ describe("LiveKitSession", () => {
.spyOn((session as any)._audioPipeline, "applyNoiseSuppressor")
.mockResolvedValue(undefined);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(noiseSpy).toHaveBeenCalled();
noiseSpy.mockRestore();
@@ -1280,7 +1312,7 @@ describe("LiveKitSession", () => {
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 7, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 7, "ws://localhost:7880", true);
vi.clearAllMocks();
// Session is already in "connected" state with channelId=7 after handleVoiceToken
@@ -1310,7 +1342,7 @@ describe("LiveKitSession", () => {
session.setOnError(errorCb);
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("some error"));
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("Microphone unavailable — joined in listen-only mode");
});
@@ -1321,7 +1353,7 @@ describe("LiveKitSession", () => {
const subSpy = vi.spyOn((session as any)._audioElements, "applyRemoteAudioSubscriptionState");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
// Should be called with the deafened state
expect(subSpy).toHaveBeenCalledWith(true);
@@ -1332,7 +1364,7 @@ describe("LiveKitSession", () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
// setMicrophoneEnabled(false) should have been called (shouldEnableMicrophone = false when muted)
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
@@ -1342,7 +1374,7 @@ describe("LiveKitSession", () => {
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(setListenOnly).toHaveBeenCalledWith(false);
});
@@ -1350,7 +1382,7 @@ describe("LiveKitSession", () => {
it("sets listenOnly(true) when mic fails", async () => {
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("fail"));
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(setListenOnly).toHaveBeenCalledWith(true);
});
@@ -1556,6 +1588,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
await vi.advanceTimersByTimeAsync(2100);
@@ -1578,6 +1611,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
for (let i = 0; i < 3; i++) {
@@ -1602,6 +1636,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
// Inject a pendingJoin into the current "connecting" state
@@ -1638,6 +1673,7 @@ describe("LiveKitSession", () => {
"/livekit",
1,
"ws://localhost:7880",
true,
);
expect(result).toBe(true);
@@ -1648,11 +1684,11 @@ describe("LiveKitSession", () => {
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
await (session as any).connectAndSetup("token-1", "/livekit", 1, "ws://localhost:7880");
await (session as any).connectAndSetup("token-1", "/livekit", 1, "ws://localhost:7880", true);
expect((session as any)._state.type).toBe("connected");
const leaveSpy = vi.spyOn(session, "leaveVoice");
await (session as any).connectAndSetup("token-2", "/livekit", 2, "ws://localhost:7880");
await (session as any).connectAndSetup("token-2", "/livekit", 2, "ws://localhost:7880", true);
expect(leaveSpy).toHaveBeenCalledWith(false);
leaveSpy.mockRestore();
@@ -1665,12 +1701,12 @@ describe("LiveKitSession", () => {
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token-1", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("token-1", "/livekit", 1, "ws://localhost:7880", true);
mockRoom.state = "connected";
const refreshSpy = vi.spyOn(session, "handleVoiceTokenRefresh");
await session.handleVoiceToken("token-2", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("token-2", "/livekit", 1, "ws://localhost:7880", true);
expect(refreshSpy).toHaveBeenCalledWith("token-2");
expect(mockRoom.connect).toHaveBeenCalledTimes(1);
@@ -1686,11 +1722,18 @@ describe("LiveKitSession", () => {
.mockImplementationOnce(() => firstConnect.promise)
.mockResolvedValue(undefined);
const firstJoin = session.handleVoiceToken("token-1", "/livekit-1", 1, "ws://localhost:7881");
await Promise.resolve();
const firstJoin = session.handleVoiceToken(
"token-1",
"/livekit-1",
1,
"ws://localhost:7881",
true,
);
// Flush microtasks so E2EE async steps resolve before connect
await vi.advanceTimersByTimeAsync(0);
await session.handleVoiceToken("token-2", "/livekit-2", 2, "ws://localhost:7882");
await session.handleVoiceToken("token-3", "/livekit-3", 3, "ws://localhost:7883");
await session.handleVoiceToken("token-2", "/livekit-2", 2, "ws://localhost:7882", true);
await session.handleVoiceToken("token-3", "/livekit-3", 3, "ws://localhost:7883", true);
const s = (session as any)._state;
expect(s.type).toBe("connecting");
@@ -1890,7 +1933,7 @@ describe("LiveKitSession", () => {
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
@@ -1932,7 +1975,7 @@ describe("LiveKitSession", () => {
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880");
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
(session as any).clearTokenRefreshTimer();
+190
View File
@@ -2913,3 +2913,193 @@ describe("send edge cases", () => {
expect(client.getState()).toBe("connected");
});
});
// ---------------------------------------------------------------------------
// Listener registry mechanics (no Tauri connection needed)
// ---------------------------------------------------------------------------
describe("listener registry mechanics (on/off/dispatch)", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("on() registers a listener and returns an unsubscribe function", () => {
const listener = vi.fn();
const unsub = client.on("chat_message", listener);
expect(typeof unsub).toBe("function");
});
it("off via returned unsubscribe removes a specific listener", async () => {
// Connect so we can dispatch messages through the proxy
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const calls: string[] = [];
const listenerA = () => calls.push("A");
const listenerB = () => calls.push("B");
client.on("chat_message", listenerA);
const unsubB = client.on("chat_message", listenerB);
// Remove only B
unsubB();
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
expect(calls).toEqual(["A"]);
});
it("multiple listeners on the same event type all get called", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const calls: string[] = [];
client.on("chat_message", () => calls.push("first"));
client.on("chat_message", () => calls.push("second"));
client.on("chat_message", () => calls.push("third"));
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
expect(calls).toEqual(["first", "second", "third"]);
});
it("listener removal mid-dispatch does not crash", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const calls: string[] = [];
let unsubSelf: (() => void) | null = null;
// This listener unsubscribes itself when called
unsubSelf = client.on("chat_message", () => {
calls.push("self-removing");
unsubSelf!();
});
// Second listener should still be called
client.on("chat_message", () => calls.push("survivor"));
const msgJson = JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
});
// First dispatch — self-removing listener fires then removes itself
emitTauriEvent("ws-message", msgJson);
expect(calls).toContain("self-removing");
expect(calls).toContain("survivor");
// Second dispatch — only survivor should fire
calls.length = 0;
emitTauriEvent("ws-message", msgJson);
expect(calls).toEqual(["survivor"]);
});
it("unknown event type dispatch does not throw", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Dispatch a completely unknown event type — should not crash
expect(() => {
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "totally_unknown_event",
payload: { foo: "bar" },
}),
);
}).not.toThrow();
});
it("error boundary: throwing listener does not prevent next listener from running", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const received: string[] = [];
client.on("chat_message", () => {
throw new Error("first listener explodes");
});
client.on("chat_message", (payload) => {
received.push((payload as { content: string }).content);
});
client.on("chat_message", () => {
throw new Error("third listener also explodes");
});
client.on("chat_message", (payload) => {
received.push("fourth:" + (payload as { content: string }).content);
});
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "hello",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
// Both non-throwing listeners should have received the message
expect(received).toEqual(["hello", "fourth:hello"]);
});
});
+63
View File
@@ -97,6 +97,69 @@ func TestListRoles_OrderedByPositionDesc(t *testing.T) {
}
}
// ─── GetUserWithRole tests ────────────────────────────────────────────────────
func TestGetUserWithRole_Found(t *testing.T) {
database := newTestDB(t)
uid, err := database.CreateUser("joinuser", "hash", 4) // Member role
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, role, err := database.GetUserWithRole(uid)
if err != nil {
t.Fatalf("GetUserWithRole: %v", err)
}
if user == nil || role == nil {
t.Fatal("GetUserWithRole returned nil user or role")
}
if user.ID != uid {
t.Errorf("user.ID = %d, want %d", user.ID, uid)
}
if user.Username != "joinuser" {
t.Errorf("user.Username = %q, want %q", user.Username, "joinuser")
}
if role.ID != 4 {
t.Errorf("role.ID = %d, want 4 (Member)", role.ID)
}
if role.Name != "Member" {
t.Errorf("role.Name = %q, want %q", role.Name, "Member")
}
if role.Permissions == 0 {
t.Error("role.Permissions = 0, want non-zero for Member")
}
}
func TestGetUserWithRole_NotFound(t *testing.T) {
database := newTestDB(t)
user, role, err := database.GetUserWithRole(9999)
if err != nil {
t.Fatalf("GetUserWithRole(not found): %v", err)
}
if user != nil || role != nil {
t.Error("GetUserWithRole returned non-nil for missing user")
}
}
func TestGetUserWithRole_BoolConversions(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("booluser", "hash", 4)
user, role, err := database.GetUserWithRole(uid)
if err != nil {
t.Fatalf("GetUserWithRole: %v", err)
}
// Fresh user should not be banned.
if user.Banned {
t.Error("user.Banned = true, want false for new user")
}
// Member role has is_default=1.
if !role.IsDefault {
t.Error("role.IsDefault = false, want true for Member")
}
}
// ─── ListInvites tests ────────────────────────────────────────────────────────
func TestListInvites_Empty(t *testing.T) {
+59
View File
@@ -47,3 +47,62 @@ func (d *DB) ListRoles() ([]*Role, error) {
}
return roles, rows.Err()
}
// GetRoleForUser returns only the role for a given user via a single JOIN.
// Unlike GetUserWithRole, this does not fetch sensitive user columns (password,
// TOTP secret). Use this on hot paths like permission checks.
// Returns (nil, nil) when the user is not found.
func (d *DB) GetRoleForUser(userID int64) (*Role, error) {
row := d.sqlDB.QueryRow(
`SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = ?`,
userID,
)
r := &Role{}
var isDefault int
err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetRoleForUser: %w", err)
}
r.IsDefault = isDefault != 0
return r, nil
}
// GetUserWithRole returns the user and their role in a single query.
// Returns (nil, nil, nil) when the user is not found.
func (d *DB) GetUserWithRole(userID int64) (*User, *Role, error) {
row := d.sqlDB.QueryRow(
`SELECT u.id, u.username, u.password, u.avatar, u.role_id,
u.totp_secret, u.status, u.created_at, u.last_seen,
u.banned, u.ban_reason, u.ban_expires,
r.id, r.name, r.color, r.permissions, r.position, r.is_default
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = ?`,
userID,
)
u := &User{}
r := &Role{}
var banned, isDefault int
err := row.Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID,
&u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen,
&banned, &u.BanReason, &u.BanExpires,
&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("GetUserWithRole: %w", err)
}
u.Banned = banned != 0
r.IsDefault = isDefault != 0
return u, r, nil
}
+479
View File
@@ -0,0 +1,479 @@
package ws
import (
"encoding/json"
"fmt"
)
// Command is the minimal interface for all client-to-server commands.
type Command interface {
// Type returns the message type constant (e.g. MsgTypeChatSend).
Type() string
// UserID returns the authenticated user who sent this command.
UserID() int64
}
// ChannelScoped is an optional interface for commands targeting a channel.
type ChannelScoped interface {
ChannelID() int64
}
// ── Concrete command structs ────────────────────────────────────────────────
// PingCmd represents a client ping (heartbeat).
type PingCmd struct {
userID int64
}
func (c PingCmd) Type() string { return MsgTypePing }
func (c PingCmd) UserID() int64 { return c.userID }
// ChatSendCmd represents a chat_send message.
type ChatSendCmd struct {
userID int64
reqID string
channelID int64
content string
replyTo *int64
attachments []string
}
func (c ChatSendCmd) Type() string { return MsgTypeChatSend }
func (c ChatSendCmd) UserID() int64 { return c.userID }
func (c ChatSendCmd) ChannelID() int64 { return c.channelID }
func (c ChatSendCmd) ReqID() string { return c.reqID }
func (c ChatSendCmd) Content() string { return c.content }
func (c ChatSendCmd) ReplyTo() *int64 { return c.replyTo }
func (c ChatSendCmd) Attachments() []string {
dst := make([]string, len(c.attachments))
copy(dst, c.attachments)
return dst
}
// ChatEditCmd represents a chat_edit message.
type ChatEditCmd struct {
userID int64
reqID string
messageID int64
content string
}
func (c ChatEditCmd) Type() string { return MsgTypeChatEdit }
func (c ChatEditCmd) UserID() int64 { return c.userID }
func (c ChatEditCmd) ReqID() string { return c.reqID }
func (c ChatEditCmd) MessageID() int64 { return c.messageID }
func (c ChatEditCmd) Content() string { return c.content }
// ChatDeleteCmd represents a chat_delete message.
type ChatDeleteCmd struct {
userID int64
reqID string
messageID int64
}
func (c ChatDeleteCmd) Type() string { return MsgTypeChatDelete }
func (c ChatDeleteCmd) UserID() int64 { return c.userID }
func (c ChatDeleteCmd) ReqID() string { return c.reqID }
func (c ChatDeleteCmd) MessageID() int64 { return c.messageID }
// TypingStartCmd represents a typing_start message.
type TypingStartCmd struct {
userID int64
channelID int64
}
func (c TypingStartCmd) Type() string { return MsgTypeTypingStart }
func (c TypingStartCmd) UserID() int64 { return c.userID }
func (c TypingStartCmd) ChannelID() int64 { return c.channelID }
// PresenceUpdateCmd represents a presence_update message.
type PresenceUpdateCmd struct {
userID int64
status string
}
func (c PresenceUpdateCmd) Type() string { return MsgTypePresenceUpdate }
func (c PresenceUpdateCmd) UserID() int64 { return c.userID }
func (c PresenceUpdateCmd) Status() string { return c.status }
// ChannelFocusCmd represents a channel_focus message.
type ChannelFocusCmd struct {
userID int64
channelID int64
}
func (c ChannelFocusCmd) Type() string { return MsgTypeChannelFocus }
func (c ChannelFocusCmd) UserID() int64 { return c.userID }
func (c ChannelFocusCmd) ChannelID() int64 { return c.channelID }
// ReactionAddCmd represents a reaction_add message.
type ReactionAddCmd struct {
userID int64
messageID int64
emoji string
}
func (c ReactionAddCmd) Type() string { return MsgTypeReactionAdd }
func (c ReactionAddCmd) UserID() int64 { return c.userID }
func (c ReactionAddCmd) MessageID() int64 { return c.messageID }
func (c ReactionAddCmd) Emoji() string { return c.emoji }
// ReactionRemoveCmd represents a reaction_remove message.
type ReactionRemoveCmd struct {
userID int64
messageID int64
emoji string
}
func (c ReactionRemoveCmd) Type() string { return MsgTypeReactionRemove }
func (c ReactionRemoveCmd) UserID() int64 { return c.userID }
func (c ReactionRemoveCmd) MessageID() int64 { return c.messageID }
func (c ReactionRemoveCmd) Emoji() string { return c.emoji }
// VoiceJoinCmd represents a voice_join message.
type VoiceJoinCmd struct {
userID int64
channelID int64
}
func (c VoiceJoinCmd) Type() string { return MsgTypeVoiceJoin }
func (c VoiceJoinCmd) UserID() int64 { return c.userID }
func (c VoiceJoinCmd) ChannelID() int64 { return c.channelID }
// VoiceLeaveCmd represents a voice_leave message.
type VoiceLeaveCmd struct {
userID int64
}
func (c VoiceLeaveCmd) Type() string { return MsgTypeVoiceLeave }
func (c VoiceLeaveCmd) UserID() int64 { return c.userID }
// VoiceTokenRefreshCmd represents a voice_token_refresh message.
type VoiceTokenRefreshCmd struct {
userID int64
}
func (c VoiceTokenRefreshCmd) Type() string { return MsgTypeVoiceTokenRefresh }
func (c VoiceTokenRefreshCmd) UserID() int64 { return c.userID }
// VoiceMuteCmd represents a voice_mute message.
type VoiceMuteCmd struct {
userID int64
muted bool
}
func (c VoiceMuteCmd) Type() string { return MsgTypeVoiceMute }
func (c VoiceMuteCmd) UserID() int64 { return c.userID }
func (c VoiceMuteCmd) Muted() bool { return c.muted }
// VoiceDeafenCmd represents a voice_deafen message.
type VoiceDeafenCmd struct {
userID int64
deafened bool
}
func (c VoiceDeafenCmd) Type() string { return MsgTypeVoiceDeafen }
func (c VoiceDeafenCmd) UserID() int64 { return c.userID }
func (c VoiceDeafenCmd) Deafened() bool { return c.deafened }
// VoiceCameraCmd represents a voice_camera message.
type VoiceCameraCmd struct {
userID int64
enabled bool
}
func (c VoiceCameraCmd) Type() string { return MsgTypeVoiceCamera }
func (c VoiceCameraCmd) UserID() int64 { return c.userID }
func (c VoiceCameraCmd) Enabled() bool { return c.enabled }
// VoiceScreenshareCmd represents a voice_screenshare message.
type VoiceScreenshareCmd struct {
userID int64
enabled bool
}
func (c VoiceScreenshareCmd) Type() string { return MsgTypeVoiceScreenshare }
func (c VoiceScreenshareCmd) UserID() int64 { return c.userID }
func (c VoiceScreenshareCmd) Enabled() bool { return c.enabled }
// VoiceE2EEAnnounceCmd represents a voice_e2ee_announce message.
type VoiceE2EEAnnounceCmd struct {
userID int64
publicKey string
}
func (c VoiceE2EEAnnounceCmd) Type() string { return MsgTypeVoiceE2EEAnnounce }
func (c VoiceE2EEAnnounceCmd) UserID() int64 { return c.userID }
func (c VoiceE2EEAnnounceCmd) PublicKey() string { return c.publicKey }
// VoiceE2EEOfferCmd represents a voice_e2ee_offer message.
type VoiceE2EEOfferCmd struct {
userID int64
targetUserID int64
encryptedKey string
iv string
}
func (c VoiceE2EEOfferCmd) Type() string { return MsgTypeVoiceE2EEOffer }
func (c VoiceE2EEOfferCmd) UserID() int64 { return c.userID }
func (c VoiceE2EEOfferCmd) TargetUserID() int64 { return c.targetUserID }
func (c VoiceE2EEOfferCmd) EncryptedKey() string { return c.encryptedKey }
func (c VoiceE2EEOfferCmd) IV() string { return c.iv }
// ── Command constructors ────────────────────────────────────────────────────
// commandConstructors maps message types to functions that parse payloads
// into typed Commands. The userID and reqID come from the envelope and
// authenticated client; raw is the JSON payload body.
// Unexported to prevent accidental mutation; use getCommandConstructor for lookups.
var commandConstructors = map[string]func(userID int64, reqID string, raw json.RawMessage) (Command, error){
MsgTypePing: func(userID int64, _ string, _ json.RawMessage) (Command, error) {
return PingCmd{userID: userID}, nil
},
MsgTypeChatSend: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
Content string `json:"content"`
ReplyTo *int64 `json:"reply_to"`
Attachments []string `json:"attachments"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_send payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if len(p.Attachments) > 10 {
return nil, fmt.Errorf("too many attachments (max 10)")
}
// TODO: validate attachment URL scheme (require https://) to prevent
// javascript:, data:, or file: URLs from being stored and relayed.
for i, url := range p.Attachments {
if len(url) > 2048 {
return nil, fmt.Errorf("attachment[%d] URL too long (max 2048)", i)
}
}
attachments := make([]string, len(p.Attachments))
copy(attachments, p.Attachments)
return ChatSendCmd{
userID: userID,
reqID: reqID,
channelID: chID,
content: p.Content,
replyTo: p.ReplyTo,
attachments: attachments,
}, nil
},
MsgTypeChatEdit: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_edit payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ChatEditCmd{
userID: userID,
reqID: reqID,
messageID: msgID,
content: p.Content,
}, nil
},
MsgTypeChatDelete: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_delete payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ChatDeleteCmd{
userID: userID,
reqID: reqID,
messageID: msgID,
}, nil
},
MsgTypeTypingStart: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid typing_start payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return TypingStartCmd{userID: userID, channelID: chID}, nil
},
MsgTypePresenceUpdate: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid presence_update payload: %w", err)
}
return PresenceUpdateCmd{userID: userID, status: p.Status}, nil
},
MsgTypeChannelFocus: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid channel_focus payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return ChannelFocusCmd{userID: userID, channelID: chID}, nil
},
MsgTypeReactionAdd: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Emoji string `json:"emoji"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid reaction_add payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ReactionAddCmd{userID: userID, messageID: msgID, emoji: p.Emoji}, nil
},
MsgTypeReactionRemove: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Emoji string `json:"emoji"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid reaction_remove payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ReactionRemoveCmd{userID: userID, messageID: msgID, emoji: p.Emoji}, nil
},
MsgTypeVoiceJoin: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_join payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return VoiceJoinCmd{userID: userID, channelID: chID}, nil
},
MsgTypeVoiceLeave: func(userID int64, _ string, _ json.RawMessage) (Command, error) {
return VoiceLeaveCmd{userID: userID}, nil
},
MsgTypeVoiceTokenRefresh: func(userID int64, _ string, _ json.RawMessage) (Command, error) {
return VoiceTokenRefreshCmd{userID: userID}, nil
},
MsgTypeVoiceMute: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Muted bool `json:"muted"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_mute payload: %w", err)
}
return VoiceMuteCmd{userID: userID, muted: p.Muted}, nil
},
MsgTypeVoiceDeafen: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Deafened bool `json:"deafened"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_deafen payload: %w", err)
}
return VoiceDeafenCmd{userID: userID, deafened: p.Deafened}, nil
},
MsgTypeVoiceCamera: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_camera payload: %w", err)
}
return VoiceCameraCmd{userID: userID, enabled: p.Enabled}, nil
},
MsgTypeVoiceScreenshare: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_screenshare payload: %w", err)
}
return VoiceScreenshareCmd{userID: userID, enabled: p.Enabled}, nil
},
MsgTypeVoiceE2EEAnnounce: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
PublicKey string `json:"public_key"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_e2ee_announce payload: %w", err)
}
return VoiceE2EEAnnounceCmd{userID: userID, publicKey: p.PublicKey}, nil
},
MsgTypeVoiceE2EEOffer: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
TargetUserID int64 `json:"target_user_id"`
EncryptedKey string `json:"encrypted_key"`
IV string `json:"iv"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_e2ee_offer payload: %w", err)
}
return VoiceE2EEOfferCmd{
userID: userID,
targetUserID: p.TargetUserID,
encryptedKey: p.EncryptedKey,
iv: p.IV,
}, nil
},
}
// getCommandConstructor returns the constructor for a message type, if registered.
func getCommandConstructor(msgType string) (func(int64, string, json.RawMessage) (Command, error), bool) {
ctor, ok := commandConstructors[msgType]
return ctor, ok
}
+433
View File
@@ -0,0 +1,433 @@
package ws
import (
"encoding/json"
"testing"
)
// allClientToServerTypes returns every client-to-server message type constant
// that should have a CommandConstructor entry (excludes "auth" which is handled
// separately by the auth flow).
func allClientToServerTypes() []string {
return []string{
MsgTypePing,
MsgTypeChatSend,
MsgTypeChatEdit,
MsgTypeChatDelete,
MsgTypeTypingStart,
MsgTypePresenceUpdate,
MsgTypeChannelFocus,
MsgTypeReactionAdd,
MsgTypeReactionRemove,
MsgTypeVoiceJoin,
MsgTypeVoiceLeave,
MsgTypeVoiceTokenRefresh,
MsgTypeVoiceMute,
MsgTypeVoiceDeafen,
MsgTypeVoiceCamera,
MsgTypeVoiceScreenshare,
MsgTypeVoiceE2EEAnnounce,
MsgTypeVoiceE2EEOffer,
}
}
func TestCommandConstructorsCoverage(t *testing.T) {
for _, msgType := range allClientToServerTypes() {
if _, ok := commandConstructors[msgType]; !ok {
t.Errorf("commandConstructors missing entry for %q", msgType)
}
}
}
func TestCommandTypeAndUserID(t *testing.T) {
tests := []struct {
name string
cmd Command
wantType string
wantUID int64
}{
{"PingCmd", PingCmd{userID: 1}, MsgTypePing, 1},
{"ChatSendCmd", ChatSendCmd{userID: 2, channelID: 10}, MsgTypeChatSend, 2},
{"ChatEditCmd", ChatEditCmd{userID: 3, messageID: 20}, MsgTypeChatEdit, 3},
{"ChatDeleteCmd", ChatDeleteCmd{userID: 4, messageID: 30}, MsgTypeChatDelete, 4},
{"TypingStartCmd", TypingStartCmd{userID: 5, channelID: 11}, MsgTypeTypingStart, 5},
{"PresenceUpdateCmd", PresenceUpdateCmd{userID: 6, status: "online"}, MsgTypePresenceUpdate, 6},
{"ChannelFocusCmd", ChannelFocusCmd{userID: 7, channelID: 12}, MsgTypeChannelFocus, 7},
{"ReactionAddCmd", ReactionAddCmd{userID: 8, messageID: 40, emoji: "👍"}, MsgTypeReactionAdd, 8},
{"ReactionRemoveCmd", ReactionRemoveCmd{userID: 9, messageID: 41, emoji: "👎"}, MsgTypeReactionRemove, 9},
{"VoiceJoinCmd", VoiceJoinCmd{userID: 10, channelID: 13}, MsgTypeVoiceJoin, 10},
{"VoiceLeaveCmd", VoiceLeaveCmd{userID: 11}, MsgTypeVoiceLeave, 11},
{"VoiceTokenRefreshCmd", VoiceTokenRefreshCmd{userID: 12}, MsgTypeVoiceTokenRefresh, 12},
{"VoiceMuteCmd", VoiceMuteCmd{userID: 13, muted: true}, MsgTypeVoiceMute, 13},
{"VoiceDeafenCmd", VoiceDeafenCmd{userID: 14, deafened: true}, MsgTypeVoiceDeafen, 14},
{"VoiceCameraCmd", VoiceCameraCmd{userID: 15, enabled: true}, MsgTypeVoiceCamera, 15},
{"VoiceScreenshareCmd", VoiceScreenshareCmd{userID: 16, enabled: true}, MsgTypeVoiceScreenshare, 16},
{"VoiceE2EEAnnounceCmd", VoiceE2EEAnnounceCmd{userID: 17, publicKey: "abc"}, MsgTypeVoiceE2EEAnnounce, 17},
{"VoiceE2EEOfferCmd", VoiceE2EEOfferCmd{userID: 18, targetUserID: 99}, MsgTypeVoiceE2EEOffer, 18},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cmd.Type(); got != tt.wantType {
t.Errorf("Type() = %q, want %q", got, tt.wantType)
}
if got := tt.cmd.UserID(); got != tt.wantUID {
t.Errorf("UserID() = %d, want %d", got, tt.wantUID)
}
})
}
}
func TestCommandChannelScoped(t *testing.T) {
tests := []struct {
name string
cmd Command
wantChID int64
isScoped bool
}{
{"ChatSendCmd", ChatSendCmd{channelID: 100}, 100, true},
{"TypingStartCmd", TypingStartCmd{channelID: 200}, 200, true},
{"ChannelFocusCmd", ChannelFocusCmd{channelID: 300}, 300, true},
{"VoiceJoinCmd", VoiceJoinCmd{channelID: 400}, 400, true},
{"PingCmd", PingCmd{userID: 1}, 0, false},
{"VoiceLeaveCmd", VoiceLeaveCmd{userID: 1}, 0, false},
{"PresenceUpdateCmd", PresenceUpdateCmd{userID: 1}, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, ok := tt.cmd.(ChannelScoped)
if ok != tt.isScoped {
t.Errorf("ChannelScoped assertion = %v, want %v", ok, tt.isScoped)
}
if ok && cs.ChannelID() != tt.wantChID {
t.Errorf("ChannelID() = %d, want %d", cs.ChannelID(), tt.wantChID)
}
})
}
}
func TestCommandConstructorParseValid(t *testing.T) {
tests := []struct {
name string
msgType string
payload string
checkFn func(t *testing.T, cmd Command)
}{
{
name: "ping",
msgType: MsgTypePing,
payload: `{}`,
checkFn: func(t *testing.T, cmd Command) {
if cmd.Type() != MsgTypePing {
t.Errorf("Type() = %q, want %q", cmd.Type(), MsgTypePing)
}
},
},
{
name: "chat_send",
msgType: MsgTypeChatSend,
payload: `{"channel_id": 42, "content": "hello", "reply_to": 10, "attachments": ["a1"]}`,
checkFn: func(t *testing.T, cmd Command) {
cs := cmd.(ChatSendCmd)
if cs.ChannelID() != 42 {
t.Errorf("ChannelID() = %d, want 42", cs.ChannelID())
}
if cs.Content() != "hello" {
t.Errorf("Content() = %q, want %q", cs.Content(), "hello")
}
if cs.ReplyTo() == nil || *cs.ReplyTo() != 10 {
t.Errorf("ReplyTo() = %v, want 10", cs.ReplyTo())
}
if len(cs.Attachments()) != 1 || cs.Attachments()[0] != "a1" {
t.Errorf("Attachments() = %v, want [a1]", cs.Attachments())
}
},
},
{
name: "chat_edit",
msgType: MsgTypeChatEdit,
payload: `{"message_id": 99, "content": "updated"}`,
checkFn: func(t *testing.T, cmd Command) {
ce := cmd.(ChatEditCmd)
if ce.MessageID() != 99 {
t.Errorf("MessageID() = %d, want 99", ce.MessageID())
}
if ce.Content() != "updated" {
t.Errorf("Content() = %q, want %q", ce.Content(), "updated")
}
},
},
{
name: "chat_delete",
msgType: MsgTypeChatDelete,
payload: `{"message_id": 55}`,
checkFn: func(t *testing.T, cmd Command) {
cd := cmd.(ChatDeleteCmd)
if cd.MessageID() != 55 {
t.Errorf("MessageID() = %d, want 55", cd.MessageID())
}
},
},
{
name: "typing_start",
msgType: MsgTypeTypingStart,
payload: `{"channel_id": 7}`,
checkFn: func(t *testing.T, cmd Command) {
ts := cmd.(TypingStartCmd)
if ts.ChannelID() != 7 {
t.Errorf("ChannelID() = %d, want 7", ts.ChannelID())
}
},
},
{
name: "presence_update",
msgType: MsgTypePresenceUpdate,
payload: `{"status": "idle"}`,
checkFn: func(t *testing.T, cmd Command) {
pu := cmd.(PresenceUpdateCmd)
if pu.Status() != "idle" {
t.Errorf("Status() = %q, want %q", pu.Status(), "idle")
}
},
},
{
name: "channel_focus",
msgType: MsgTypeChannelFocus,
payload: `{"channel_id": 33}`,
checkFn: func(t *testing.T, cmd Command) {
cf := cmd.(ChannelFocusCmd)
if cf.ChannelID() != 33 {
t.Errorf("ChannelID() = %d, want 33", cf.ChannelID())
}
},
},
{
name: "reaction_add",
msgType: MsgTypeReactionAdd,
payload: `{"message_id": 77, "emoji": "🔥"}`,
checkFn: func(t *testing.T, cmd Command) {
ra := cmd.(ReactionAddCmd)
if ra.MessageID() != 77 {
t.Errorf("MessageID() = %d, want 77", ra.MessageID())
}
if ra.Emoji() != "🔥" {
t.Errorf("Emoji() = %q, want %q", ra.Emoji(), "🔥")
}
},
},
{
name: "reaction_remove",
msgType: MsgTypeReactionRemove,
payload: `{"message_id": 88, "emoji": "👎"}`,
checkFn: func(t *testing.T, cmd Command) {
rr := cmd.(ReactionRemoveCmd)
if rr.MessageID() != 88 {
t.Errorf("MessageID() = %d, want 88", rr.MessageID())
}
},
},
{
name: "voice_join",
msgType: MsgTypeVoiceJoin,
payload: `{"channel_id": 50}`,
checkFn: func(t *testing.T, cmd Command) {
vj := cmd.(VoiceJoinCmd)
if vj.ChannelID() != 50 {
t.Errorf("ChannelID() = %d, want 50", vj.ChannelID())
}
},
},
{
name: "voice_leave",
msgType: MsgTypeVoiceLeave,
payload: `{}`,
checkFn: func(t *testing.T, cmd Command) {
if cmd.Type() != MsgTypeVoiceLeave {
t.Errorf("Type() = %q, want %q", cmd.Type(), MsgTypeVoiceLeave)
}
},
},
{
name: "voice_token_refresh",
msgType: MsgTypeVoiceTokenRefresh,
payload: `{}`,
checkFn: func(t *testing.T, cmd Command) {
if cmd.Type() != MsgTypeVoiceTokenRefresh {
t.Errorf("Type() = %q, want %q", cmd.Type(), MsgTypeVoiceTokenRefresh)
}
},
},
{
name: "voice_mute",
msgType: MsgTypeVoiceMute,
payload: `{"muted": true}`,
checkFn: func(t *testing.T, cmd Command) {
vm := cmd.(VoiceMuteCmd)
if !vm.Muted() {
t.Error("Muted() = false, want true")
}
},
},
{
name: "voice_deafen",
msgType: MsgTypeVoiceDeafen,
payload: `{"deafened": true}`,
checkFn: func(t *testing.T, cmd Command) {
vd := cmd.(VoiceDeafenCmd)
if !vd.Deafened() {
t.Error("Deafened() = false, want true")
}
},
},
{
name: "voice_camera",
msgType: MsgTypeVoiceCamera,
payload: `{"enabled": true}`,
checkFn: func(t *testing.T, cmd Command) {
vc := cmd.(VoiceCameraCmd)
if !vc.Enabled() {
t.Error("Enabled() = false, want true")
}
},
},
{
name: "voice_screenshare",
msgType: MsgTypeVoiceScreenshare,
payload: `{"enabled": false}`,
checkFn: func(t *testing.T, cmd Command) {
vs := cmd.(VoiceScreenshareCmd)
if vs.Enabled() {
t.Error("Enabled() = true, want false")
}
},
},
{
name: "voice_e2ee_announce",
msgType: MsgTypeVoiceE2EEAnnounce,
payload: `{"public_key": "dGVzdA=="}`,
checkFn: func(t *testing.T, cmd Command) {
va := cmd.(VoiceE2EEAnnounceCmd)
if va.PublicKey() != "dGVzdA==" {
t.Errorf("PublicKey() = %q, want %q", va.PublicKey(), "dGVzdA==")
}
},
},
{
name: "voice_e2ee_offer",
msgType: MsgTypeVoiceE2EEOffer,
payload: `{"target_user_id": 99, "encrypted_key": "abc", "iv": "def"}`,
checkFn: func(t *testing.T, cmd Command) {
vo := cmd.(VoiceE2EEOfferCmd)
if vo.TargetUserID() != 99 {
t.Errorf("TargetUserID() = %d, want 99", vo.TargetUserID())
}
if vo.EncryptedKey() != "abc" {
t.Errorf("EncryptedKey() = %q, want %q", vo.EncryptedKey(), "abc")
}
if vo.IV() != "def" {
t.Errorf("IV() = %q, want %q", vo.IV(), "def")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctor, ok := commandConstructors[tt.msgType]
if !ok {
t.Fatalf("no constructor for %q", tt.msgType)
}
cmd, err := ctor(42, "req-1", json.RawMessage(tt.payload))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.UserID() != 42 {
t.Errorf("UserID() = %d, want 42", cmd.UserID())
}
tt.checkFn(t, cmd)
})
}
}
func TestCommandConstructorRejectsInvalidJSON(t *testing.T) {
// All constructors that parse a payload should reject garbage JSON.
typesWithPayload := []string{
MsgTypeChatSend,
MsgTypeChatEdit,
MsgTypeChatDelete,
MsgTypeTypingStart,
MsgTypePresenceUpdate,
MsgTypeChannelFocus,
MsgTypeReactionAdd,
MsgTypeReactionRemove,
MsgTypeVoiceJoin,
MsgTypeVoiceMute,
MsgTypeVoiceDeafen,
MsgTypeVoiceCamera,
MsgTypeVoiceScreenshare,
MsgTypeVoiceE2EEAnnounce,
MsgTypeVoiceE2EEOffer,
}
badJSON := json.RawMessage(`{not valid json`)
for _, msgType := range typesWithPayload {
t.Run(msgType, func(t *testing.T) {
ctor := commandConstructors[msgType]
_, err := ctor(1, "req-1", badJSON)
if err == nil {
t.Errorf("expected error for invalid JSON, got nil")
}
})
}
}
func TestCommandConstructorNoPayloadTypes(t *testing.T) {
// These types ignore the payload — they should succeed with nil.
noPayloadTypes := []string{
MsgTypePing,
MsgTypeVoiceLeave,
MsgTypeVoiceTokenRefresh,
}
for _, msgType := range noPayloadTypes {
t.Run(msgType, func(t *testing.T) {
ctor := commandConstructors[msgType]
cmd, err := ctor(5, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.UserID() != 5 {
t.Errorf("UserID() = %d, want 5", cmd.UserID())
}
})
}
}
func TestCommandChatSendReqID(t *testing.T) {
ctor := commandConstructors[MsgTypeChatSend]
cmd, err := ctor(1, "abc-123", json.RawMessage(`{"channel_id": 1, "content": "hi"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cmd.(ChatSendCmd)
if cs.ReqID() != "abc-123" {
t.Errorf("ReqID() = %q, want %q", cs.ReqID(), "abc-123")
}
}
func TestCommandChatSendNilReplyTo(t *testing.T) {
ctor := commandConstructors[MsgTypeChatSend]
cmd, err := ctor(1, "", json.RawMessage(`{"channel_id": 1, "content": "hi"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cmd.(ChatSendCmd)
if cs.ReplyTo() != nil {
t.Errorf("ReplyTo() = %v, want nil", cs.ReplyTo())
}
}
func TestCommandAttachmentsDefensiveCopy(t *testing.T) {
cmd := ChatSendCmd{attachments: []string{"a", "b"}}
got := cmd.Attachments()
got[0] = "mutated"
if cmd.attachments[0] == "mutated" {
t.Error("Attachments() did not return a defensive copy")
}
}
+4 -4
View File
@@ -785,10 +785,10 @@ func TestHandleChannelFocus_InvalidChannelID(t *testing.T) {
hub.HandleMessageForTest(c, raw)
time.Sleep(20 * time.Millisecond)
// Invalid channel_id should be silently ignored — no error sent to client.
// V2 CommandConstructor rejects non-numeric channel_id with BAD_REQUEST.
code := drainForErrorCode(send, 100*time.Millisecond)
if code != "" {
t.Fatalf("expected no error for invalid channel_id, got code=%q", code)
if code != "BAD_REQUEST" {
t.Fatalf("expected BAD_REQUEST for non-numeric channel_id, got code=%q", code)
}
}
@@ -2377,7 +2377,7 @@ func TestClearVoiceChID_DoubleClearReturnsZero(t *testing.T) {
}
}
// ─── handleVoiceTokenRefresh (voice_join.go:188) ────────────────────────────
// ─── voice_token_refresh (now V2 — dispatched via handleMessage) ────────────
func voiceTokenRefreshMsg() []byte {
raw, _ := json.Marshal(map[string]any{
+116
View File
@@ -0,0 +1,116 @@
package ws
import (
"context"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// ClientInfo holds a read-only snapshot of client state for V2 handlers.
// Handlers receive this instead of a mutable *Client pointer, making them
// easier to test and reason about.
type ClientInfo struct {
UserID int64
Username string
Avatar *string
RoleName string
ReqID string
VoiceChannelID int64 // 0 if not in a voice channel
VoiceJoinToken string // opaque join-instance token for the current voice session
}
// ── Per-domain dependency structs ───────────────────────────────────────────
// PingDeps holds dependencies for the ping handler.
type PingDeps struct {
Limiter *auth.RateLimiter
}
// ChatDeps holds dependencies for chat handlers.
type ChatDeps struct {
DB *db.DB
Limiter *auth.RateLimiter
Permissions *permissions.Checker
}
// PresenceDeps holds dependencies for presence, typing, and channel focus handlers.
type PresenceDeps struct {
DB *db.DB
Limiter *auth.RateLimiter
Permissions *permissions.Checker
}
// ReactionDeps holds dependencies for reaction handlers.
type ReactionDeps struct {
DB *db.DB
Limiter *auth.RateLimiter
Permissions *permissions.Checker
}
// VoiceTokenGenerator generates LiveKit access tokens. Abstracted so V2
// handlers can be tested without a real LiveKit server.
type VoiceTokenGenerator interface {
GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error)
URL() string
}
// KeyHolderChecker reports whether a user is the E2EE key holder for a voice channel.
type KeyHolderChecker interface {
IsVoiceKeyHolder(channelID, userID int64) bool
}
// VoiceDeps holds dependencies for voice handlers.
type VoiceDeps struct {
DB *db.DB
Limiter *auth.RateLimiter
Permissions *permissions.Checker
LiveKit *LiveKitClient
TokenGen VoiceTokenGenerator // used by voice_token_refresh V2
KeyHolder KeyHolderChecker // used by voice_token_refresh V2
}
// ── V2 permission helpers ───────────────────────────────────────────────────
// requirePerm checks a channel permission via DB lookups. Returns nil if
// allowed, or a Result with a FORBIDDEN error. Used by V2 handlers that
// cannot access the Hub's requireChannelPerm method.
func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result {
if database == nil || perms == nil {
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}}
return &r
}
role, err := database.GetRoleForUser(userID)
if err != nil || role == nil {
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}}
return &r
}
if !perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm) {
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}}
return &r
}
return nil
}
// hasPerm checks a channel permission via DB lookups. Returns true if allowed.
func hasPerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool {
if database == nil || perms == nil {
return false
}
role, err := database.GetRoleForUser(userID)
if err != nil || role == nil {
return false
}
return perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm)
}
// ── V2 handler type ─────────────────────────────────────────────────────────
// HandlerV2 is the function signature for new-style (pure-ish) handlers.
// They receive a typed Command, a read-only ClientInfo snapshot, and a
// domain-specific deps struct (passed as any; handler asserts the concrete type).
// They return a Result describing what events to emit and any error.
// TODO: consider replacing `deps any` with generics (HandlerV2[D any]) to get
// compile-time type safety on deps wiring. Requires reworking the registry map.
type HandlerV2 func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result
+39
View File
@@ -0,0 +1,39 @@
package ws
import (
"fmt"
"log/slog"
)
// EmitEvents routes typed events to the appropriate broadcast methods.
// Called from readPump goroutines after a V2 handler returns.
//
// CRITICAL ordering: SequencedDMEvent MUST be checked before ChannelEvent
// because DM events implement both interfaces. The SequencedDMEvent path
// calls sendSequencedToUsers which preserves the seqMu serialization guarantee.
//
// VoiceChannelEvent MUST be checked before ExcludeSenderEvent because voice
// events implement a superset of ExcludeSender semantics but target by voice
// channel membership rather than channel focus.
func (h *Hub) EmitEvents(events []Event) {
for _, ev := range events {
switch e := ev.(type) {
case SequencedDMEvent:
h.sendSequencedToUsers(e.ChannelID(), e.ParticipantIDs(), e.Payload())
case VoiceChannelGuardedEvent:
h.sendToUserIfInVoiceChannel(e.VoiceChannelID(), e.TargetUserID(), e.Payload())
case VoiceChannelEvent:
h.sendToVoiceChannelExcept(e.VoiceChannelID(), e.ExcludeUserID(), e.Payload())
case ExcludeSenderEvent:
h.broadcastExclude(e.ChannelID(), e.ExcludeUserID(), e.Payload())
case UserTargetedEvent:
h.SendToUser(e.TargetUserID(), e.Payload())
case BroadcastAllEvent:
h.BroadcastToAll(e.Payload())
case ChannelEvent:
h.BroadcastToChannel(e.ChannelID(), e.Payload())
default:
slog.Warn("EmitEvents: unknown event type", "type", fmt.Sprintf("%T", ev))
}
}
}
+379
View File
@@ -0,0 +1,379 @@
package ws
import (
"testing"
"time"
)
// drainChan reads all pending messages from a buffered chan []byte within a
// short timeout. Returns the collected messages.
func drainChan(ch chan []byte, timeout time.Duration) [][]byte {
var msgs [][]byte
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case msg := <-ch:
msgs = append(msgs, msg)
case <-timer.C:
return msgs
}
}
}
// newEmitTestHub creates a minimal Hub suitable for EmitEvents tests.
// No DB, no limiter, no registry — just the client map, broadcast channel,
// and the locks needed for delivery.
func newEmitTestHub() *Hub {
return &Hub{
clients: make(map[int64]*Client),
broadcast: make(chan broadcastMsg, 64),
register: make(chan *Client, 16),
unregister: make(chan *Client, 16),
stop: make(chan struct{}),
replayBuf: NewEventRingBuffer(100),
voiceKeyHolders: make(map[int64]int64),
}
}
// registerEmitTestClient creates a test client, registers it directly in the
// hub's client map, and returns the send channel for assertions.
func registerEmitTestClient(h *Hub, userID, channelID int64) chan []byte {
send := make(chan []byte, 64)
c := NewTestClientWithChannel(h, userID, channelID, send)
h.clients[userID] = c
return send
}
// registerEmitTestVoiceClient creates a test client in a voice channel.
func registerEmitTestVoiceClient(h *Hub, userID, channelID, voiceChID int64) chan []byte {
send := make(chan []byte, 64)
c := NewTestClientWithChannel(h, userID, channelID, send)
SetClientVoiceChID(c, voiceChID)
h.clients[userID] = c
return send
}
// ── stub events for testing ──────────────────────────────────────────────────
type stubChannelEvent struct {
channelID int64
payload []byte
}
func (e stubChannelEvent) EventType() string { return "test_channel" }
func (e stubChannelEvent) ChannelID() int64 { return e.channelID }
func (e stubChannelEvent) Payload() []byte { return e.payload }
type stubExcludeSenderEvent struct {
channelID int64
excludeUserID int64
payload []byte
}
func (e stubExcludeSenderEvent) EventType() string { return "test_exclude" }
func (e stubExcludeSenderEvent) ChannelID() int64 { return e.channelID }
func (e stubExcludeSenderEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e stubExcludeSenderEvent) Payload() []byte { return e.payload }
type stubSequencedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e stubSequencedDMEvent) EventType() string { return "test_dm" }
func (e stubSequencedDMEvent) ChannelID() int64 { return e.channelID }
func (e stubSequencedDMEvent) ParticipantIDs() []int64 { return e.participantIDs }
func (e stubSequencedDMEvent) Payload() []byte { return e.payload }
type stubUserTargetedEvent struct {
targetUserID int64
payload []byte
}
func (e stubUserTargetedEvent) EventType() string { return "test_targeted" }
func (e stubUserTargetedEvent) TargetUserID() int64 { return e.targetUserID }
func (e stubUserTargetedEvent) Payload() []byte { return e.payload }
type stubBroadcastAllEvent struct {
payload []byte
}
func (e stubBroadcastAllEvent) EventType() string { return "test_broadcast_all" }
func (e stubBroadcastAllEvent) Payload() []byte { return e.payload }
type stubVoiceChannelEvent struct {
voiceChannelID int64
excludeUserID int64
payload []byte
}
func (e stubVoiceChannelEvent) EventType() string { return "test_voice" }
func (e stubVoiceChannelEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e stubVoiceChannelEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e stubVoiceChannelEvent) Payload() []byte { return e.payload }
type stubVoiceChannelGuardedEvent struct {
voiceChannelID int64
targetUserID int64
payload []byte
}
func (e stubVoiceChannelGuardedEvent) EventType() string { return "test_voice_guarded" }
func (e stubVoiceChannelGuardedEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e stubVoiceChannelGuardedEvent) TargetUserID() int64 { return e.targetUserID }
func (e stubVoiceChannelGuardedEvent) Payload() []byte { return e.payload }
type stubUnknownEvent struct{}
func (e stubUnknownEvent) EventType() string { return "test_unknown" }
// ── tests ────────────────────────────────────────────────────────────────────
func TestEmitEvents_ChannelEvent_CallsBroadcastToChannel(t *testing.T) {
h := newEmitTestHub()
send1 := registerEmitTestClient(h, 1, 42)
_ = registerEmitTestClient(h, 2, 99) // different channel
payload := []byte(`{"type":"test"}`)
events := []Event{stubChannelEvent{channelID: 42, payload: payload}}
// BroadcastToChannel is async (via broadcast chan), so we run the
// hub loop briefly to deliver.
go h.Run()
defer h.Stop()
h.EmitEvents(events)
// Give the hub loop time to deliver.
msgs := drainChan(send1, 100*time.Millisecond)
if len(msgs) == 0 {
t.Fatal("expected client 1 (channel 42) to receive the channel event")
}
}
func TestEmitEvents_ExcludeSenderEvent(t *testing.T) {
h := newEmitTestHub()
sendSender := registerEmitTestClient(h, 1, 42)
sendOther := registerEmitTestClient(h, 2, 42)
payload := []byte(`{"type":"typing"}`)
events := []Event{stubExcludeSenderEvent{channelID: 42, excludeUserID: 1, payload: payload}}
h.EmitEvents(events)
// broadcastExclude is synchronous — check immediately.
senderMsgs := drainChan(sendSender, 50*time.Millisecond)
otherMsgs := drainChan(sendOther, 50*time.Millisecond)
if len(senderMsgs) != 0 {
t.Errorf("sender should be excluded, got %d messages", len(senderMsgs))
}
if len(otherMsgs) != 1 {
t.Errorf("other client should receive 1 message, got %d", len(otherMsgs))
}
}
func TestEmitEvents_SequencedDMEvent(t *testing.T) {
h := newEmitTestHub()
send1 := registerEmitTestClient(h, 1, 0)
send2 := registerEmitTestClient(h, 2, 0)
send3 := registerEmitTestClient(h, 3, 0) // not a participant
payload := []byte(`{"type":"dm_msg"}`)
events := []Event{stubSequencedDMEvent{
channelID: 100,
participantIDs: []int64{1, 2},
payload: payload,
}}
h.EmitEvents(events)
msgs1 := drainChan(send1, 50*time.Millisecond)
msgs2 := drainChan(send2, 50*time.Millisecond)
msgs3 := drainChan(send3, 50*time.Millisecond)
if len(msgs1) != 1 {
t.Errorf("participant 1 should receive 1 message, got %d", len(msgs1))
}
if len(msgs2) != 1 {
t.Errorf("participant 2 should receive 1 message, got %d", len(msgs2))
}
if len(msgs3) != 0 {
t.Errorf("non-participant (client 3) should receive 0 messages, got %d", len(msgs3))
}
}
func TestEmitEvents_UserTargetedEvent(t *testing.T) {
h := newEmitTestHub()
send1 := registerEmitTestClient(h, 1, 0)
send2 := registerEmitTestClient(h, 2, 0)
payload := []byte(`{"type":"targeted"}`)
events := []Event{stubUserTargetedEvent{targetUserID: 2, payload: payload}}
h.EmitEvents(events)
msgs1 := drainChan(send1, 50*time.Millisecond)
msgs2 := drainChan(send2, 50*time.Millisecond)
if len(msgs1) != 0 {
t.Errorf("user 1 should not receive targeted event, got %d", len(msgs1))
}
if len(msgs2) != 1 {
t.Errorf("user 2 should receive 1 targeted message, got %d", len(msgs2))
}
}
func TestEmitEvents_BroadcastAllEvent(t *testing.T) {
h := newEmitTestHub()
send1 := registerEmitTestClient(h, 1, 42)
send2 := registerEmitTestClient(h, 2, 99)
payload := []byte(`{"type":"global"}`)
events := []Event{stubBroadcastAllEvent{payload: payload}}
// BroadcastToAll goes through the broadcast channel, need hub loop.
go h.Run()
defer h.Stop()
h.EmitEvents(events)
msgs1 := drainChan(send1, 100*time.Millisecond)
msgs2 := drainChan(send2, 100*time.Millisecond)
if len(msgs1) == 0 {
t.Error("user 1 should receive broadcast_all event")
}
if len(msgs2) == 0 {
t.Error("user 2 should receive broadcast_all event")
}
}
func TestEmitEvents_VoiceChannelEvent(t *testing.T) {
h := newEmitTestHub()
sendSender := registerEmitTestVoiceClient(h, 1, 0, 50)
sendOther := registerEmitTestVoiceClient(h, 2, 0, 50)
sendOutside := registerEmitTestVoiceClient(h, 3, 0, 99) // different voice channel
payload := []byte(`{"type":"voice_e2ee"}`)
events := []Event{stubVoiceChannelEvent{
voiceChannelID: 50,
excludeUserID: 1,
payload: payload,
}}
h.EmitEvents(events)
senderMsgs := drainChan(sendSender, 50*time.Millisecond)
otherMsgs := drainChan(sendOther, 50*time.Millisecond)
outsideMsgs := drainChan(sendOutside, 50*time.Millisecond)
if len(senderMsgs) != 0 {
t.Errorf("sender should be excluded from voice event, got %d", len(senderMsgs))
}
if len(otherMsgs) != 1 {
t.Errorf("voice participant should receive 1 message, got %d", len(otherMsgs))
}
if len(outsideMsgs) != 0 {
t.Errorf("client in different voice channel should not receive, got %d", len(outsideMsgs))
}
}
func TestEmitEvents_VoiceChannelGuardedEvent(t *testing.T) {
h := newEmitTestHub()
// Target is in voice channel 50.
sendTarget := registerEmitTestVoiceClient(h, 2, 0, 50)
// User 3 is in the same voice channel but is not the target.
sendOther := registerEmitTestVoiceClient(h, 3, 0, 50)
// User 4 is in a different voice channel.
sendOutside := registerEmitTestVoiceClient(h, 4, 0, 99)
payload := []byte(`{"type":"voice_e2ee_offer"}`)
events := []Event{stubVoiceChannelGuardedEvent{
voiceChannelID: 50,
targetUserID: 2,
payload: payload,
}}
h.EmitEvents(events)
targetMsgs := drainChan(sendTarget, 50*time.Millisecond)
otherMsgs := drainChan(sendOther, 50*time.Millisecond)
outsideMsgs := drainChan(sendOutside, 50*time.Millisecond)
if len(targetMsgs) != 1 {
t.Errorf("target in voice channel should receive 1 message, got %d", len(targetMsgs))
}
if len(otherMsgs) != 0 {
t.Errorf("non-target in same voice channel should not receive, got %d", len(otherMsgs))
}
if len(outsideMsgs) != 0 {
t.Errorf("client in different voice channel should not receive, got %d", len(outsideMsgs))
}
}
func TestEmitEvents_VoiceChannelGuardedEvent_TargetNotInChannel(t *testing.T) {
h := newEmitTestHub()
// Target is in voice channel 99, but event targets voice channel 50.
sendTarget := registerEmitTestVoiceClient(h, 2, 0, 99)
payload := []byte(`{"type":"voice_e2ee_offer"}`)
events := []Event{stubVoiceChannelGuardedEvent{
voiceChannelID: 50,
targetUserID: 2,
payload: payload,
}}
h.EmitEvents(events)
targetMsgs := drainChan(sendTarget, 50*time.Millisecond)
if len(targetMsgs) != 0 {
t.Errorf("target in wrong voice channel should not receive, got %d", len(targetMsgs))
}
}
func TestEmitEvents_EmptyEvents_NoOp(t *testing.T) {
h := newEmitTestHub()
_ = registerEmitTestClient(h, 1, 42)
// Should not panic or block.
h.EmitEvents(nil)
h.EmitEvents([]Event{})
}
func TestEmitEvents_MixedEventTypes_AllRouted(t *testing.T) {
h := newEmitTestHub()
sendCh := registerEmitTestClient(h, 1, 42)
sendTarget := registerEmitTestClient(h, 2, 0)
// BroadcastToChannel is async, need hub loop for channel events.
go h.Run()
defer h.Stop()
events := []Event{
stubExcludeSenderEvent{channelID: 42, excludeUserID: 99, payload: []byte(`{"e":1}`)},
stubUserTargetedEvent{targetUserID: 2, payload: []byte(`{"e":2}`)},
}
h.EmitEvents(events)
chMsgs := drainChan(sendCh, 100*time.Millisecond)
targetMsgs := drainChan(sendTarget, 100*time.Millisecond)
if len(chMsgs) != 1 {
t.Errorf("channel client should receive exclude event, got %d", len(chMsgs))
}
if len(targetMsgs) != 1 {
t.Errorf("targeted client should receive 1 message, got %d", len(targetMsgs))
}
}
func TestEmitEvents_UnknownType_LogsWarning(t *testing.T) {
h := newEmitTestHub()
_ = registerEmitTestClient(h, 1, 42)
// Should not panic; logs a warning (we verify no crash, not log content).
h.EmitEvents([]Event{stubUnknownEvent{}})
}
+294
View File
@@ -0,0 +1,294 @@
package ws
// ClientError represents an error to send back to the requesting client.
// It implements the error interface so it can be used as Result.Error.
type ClientError struct {
Code string
Message string
}
func (e ClientError) Error() string { return e.Code + ": " + e.Message }
// Result is returned by V2 handlers. It describes the outcome of processing
// a Command: zero or more Events to emit, an optional error, and an optional
// Reply (ACK) to send back to the sender.
type Result struct {
// Events to route to other clients via EmitEvents.
Events []Event
// Error, if non-nil, is sent to the client. Use ClientError for
// user-facing errors; other error types are treated as internal.
Error error
// Reply is an optional raw JSON ACK sent only to the sender
// (e.g. chat_send_ok with the new message ID).
Reply []byte
// SetChannelID, if non-nil, updates the client's focused channel.
// Used by channel_focus to mutate client state from a V2 handler.
SetChannelID *int64
// SetE2EEPubKey, if non-nil, stores the ECDH public key on the client.
// Used by voice_e2ee_announce to persist the key for later retrieval.
SetE2EEPubKey *string
// SetVoiceJoinToken, if non-nil, caches the voice join token on the client.
// Used by voice_token_refresh when falling back to the DB for the token.
SetVoiceJoinToken *string
}
// Event is the base interface for all server-to-client events.
type Event interface {
// EventType returns the outbound message type constant (e.g. MsgTypeChatMessage).
EventType() string
}
// ── Routing interfaces ──────────────────────────────────────────────────────
// EmitEvents will type-switch on these interfaces to decide how to deliver
// each Event. The check order matters: SequencedDMEvent MUST be checked
// before ChannelEvent because DM events implement both.
// ChannelEvent routes to Hub.BroadcastToChannel (sequenced, replayable).
type ChannelEvent interface {
Event
ChannelID() int64
Payload() []byte
}
// ExcludeSenderEvent routes to Hub.broadcastExclude (ephemeral, not replayed).
// Used for typing indicators in non-DM channels.
type ExcludeSenderEvent interface {
Event
ChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// SequencedDMEvent routes to Hub.sendSequencedToUsers (sequenced, replayable).
// Used for chat messages, edits, deletes, and reactions in DM channels.
type SequencedDMEvent interface {
Event
ChannelID() int64
ParticipantIDs() []int64
Payload() []byte
}
// UserTargetedEvent routes to Hub.SendToUser (direct delivery to one user).
type UserTargetedEvent interface {
Event
TargetUserID() int64
Payload() []byte
}
// BroadcastAllEvent routes to Hub.BroadcastToAll (channelID=0, all clients).
type BroadcastAllEvent interface {
Event
Payload() []byte
}
// VoiceChannelEvent routes to Hub.sendToVoiceChannelExcept (ephemeral,
// targets voice channel participants excluding sender).
type VoiceChannelEvent interface {
Event
VoiceChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// VoiceChannelGuardedEvent routes to Hub.sendToUserIfInVoiceChannel —
// atomic check-and-send that verifies the target is still in the expected
// voice channel before delivering the message, all under a single h.mu.RLock.
// Used by voice_e2ee_offer to prevent TOCTOU races with concurrent voice_leave.
type VoiceChannelGuardedEvent interface {
Event
VoiceChannelID() int64
TargetUserID() int64
Payload() []byte
}
// ── Concrete event structs ──────────────────────────────────────────────────
// MessageSentChannelEvent is a chat message broadcast to a non-DM channel.
type MessageSentChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageSentChannelEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentChannelEvent) Payload() []byte { return e.payload }
// MessageSentDMEvent is a chat message broadcast to a DM channel's participants.
type MessageSentDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageSentDMEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageSentDMEvent) Payload() []byte { return e.payload }
// MessageEditedChannelEvent is a chat_edited broadcast to a non-DM channel.
type MessageEditedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageEditedChannelEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedChannelEvent) Payload() []byte { return e.payload }
// MessageEditedDMEvent is a chat_edited broadcast to DM participants.
type MessageEditedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageEditedDMEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageEditedDMEvent) Payload() []byte { return e.payload }
// MessageDeletedChannelEvent is a chat_deleted broadcast to a non-DM channel.
type MessageDeletedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageDeletedChannelEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedChannelEvent) Payload() []byte { return e.payload }
// MessageDeletedDMEvent is a chat_deleted broadcast to DM participants.
type MessageDeletedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageDeletedDMEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageDeletedDMEvent) Payload() []byte { return e.payload }
// TypingChannelEvent is a typing indicator broadcast to a channel, excluding sender.
type TypingChannelEvent struct {
channelID int64
excludeUserID int64
payload []byte
}
func (e TypingChannelEvent) EventType() string { return MsgTypeTyping }
func (e TypingChannelEvent) ChannelID() int64 { return e.channelID }
func (e TypingChannelEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e TypingChannelEvent) Payload() []byte { return e.payload }
// TypingDMEvent is a typing indicator sent to DM participants, excluding sender.
// It uses UserTargetedEvent routing because DM typing excludes the sender and
// is delivered directly to each other participant.
type TypingDMEvent struct {
targetUserID int64
payload []byte
}
func (e TypingDMEvent) EventType() string { return MsgTypeTyping }
func (e TypingDMEvent) TargetUserID() int64 { return e.targetUserID }
func (e TypingDMEvent) Payload() []byte { return e.payload }
// PresenceEvent is a presence update broadcast to all connected clients.
type PresenceEvent struct {
payload []byte
}
func (e PresenceEvent) EventType() string { return MsgTypePresence }
func (e PresenceEvent) Payload() []byte { return e.payload }
// ReactionChannelEvent is a reaction update broadcast to a non-DM channel.
type ReactionChannelEvent struct {
channelID int64
payload []byte
}
func (e ReactionChannelEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionChannelEvent) ChannelID() int64 { return e.channelID }
func (e ReactionChannelEvent) Payload() []byte { return e.payload }
// ReactionDMEvent is a reaction update broadcast to DM participants.
type ReactionDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e ReactionDMEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionDMEvent) ChannelID() int64 { return e.channelID }
func (e ReactionDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e ReactionDMEvent) Payload() []byte { return e.payload }
// VoiceStateEvent is a voice state broadcast to all connected clients.
type VoiceStateEvent struct {
payload []byte
}
func (e VoiceStateEvent) EventType() string { return MsgTypeVoiceState }
func (e VoiceStateEvent) Payload() []byte { return e.payload }
// VoiceLeaveEvent is a voice_leave broadcast to all connected clients.
// NOTE: Currently unused by V2 handlers — voice_leave remains V1 and emits
// via h.BroadcastToAll directly. Retained as forward-compatible scaffolding.
type VoiceLeaveEvent struct {
payload []byte
}
func (e VoiceLeaveEvent) EventType() string { return MsgTypeVoiceLeaveBC }
func (e VoiceLeaveEvent) Payload() []byte { return e.payload }
// VoiceE2EEAnnounceEvent relays an ECDH public key to other voice channel participants.
type VoiceE2EEAnnounceEvent struct {
voiceChannelID int64
excludeUserID int64
payload []byte
}
func (e VoiceE2EEAnnounceEvent) EventType() string { return MsgTypeVoiceE2EEAnnounceBC }
func (e VoiceE2EEAnnounceEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEAnnounceEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e VoiceE2EEAnnounceEvent) Payload() []byte { return e.payload }
// VoiceE2EEOfferGuardedEvent relays an encrypted room key to a specific user,
// using atomic check-and-send to verify the target is still in the same voice
// channel. Satisfies VoiceChannelGuardedEvent.
type VoiceE2EEOfferGuardedEvent struct {
voiceChannelID int64
targetUserID int64
payload []byte
}
func (e VoiceE2EEOfferGuardedEvent) EventType() string { return MsgTypeVoiceE2EEOfferRelay }
func (e VoiceE2EEOfferGuardedEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEOfferGuardedEvent) TargetUserID() int64 { return e.targetUserID }
func (e VoiceE2EEOfferGuardedEvent) Payload() []byte { return e.payload }
// DMChannelOpenEvent sends a dm_channel_open notification to a specific user.
type DMChannelOpenEvent struct {
targetUserID int64
payload []byte
}
func (e DMChannelOpenEvent) EventType() string { return MsgTypeDMChannelOpen }
func (e DMChannelOpenEvent) TargetUserID() int64 { return e.targetUserID }
func (e DMChannelOpenEvent) Payload() []byte { return e.payload }
+270
View File
@@ -0,0 +1,270 @@
package ws
import (
"bytes"
"testing"
)
func TestClientErrorFormat(t *testing.T) {
err := ClientError{Code: "BAD_REQUEST", Message: "field missing"}
want := "BAD_REQUEST: field missing"
if got := err.Error(); got != want {
t.Errorf("Error() = %q, want %q", got, want)
}
}
func TestClientErrorImplementsError(t *testing.T) {
var _ error = ClientError{}
}
func TestResultEmpty(t *testing.T) {
r := Result{}
if r.Events != nil {
t.Error("expected nil Events")
}
if r.Error != nil {
t.Error("expected nil Error")
}
if r.Reply != nil {
t.Error("expected nil Reply")
}
}
func TestResultWithError(t *testing.T) {
r := Result{Error: ClientError{Code: "RATE_LIMITED", Message: "slow down"}}
if r.Error == nil {
t.Fatal("expected non-nil Error")
}
ce, ok := r.Error.(ClientError)
if !ok {
t.Fatal("expected ClientError type")
}
if ce.Code != "RATE_LIMITED" {
t.Errorf("Code = %q, want %q", ce.Code, "RATE_LIMITED")
}
}
func TestResultWithReply(t *testing.T) {
reply := []byte(`{"type":"chat_send_ok","id":"req-1"}`)
r := Result{Reply: reply}
if !bytes.Equal(r.Reply, reply) {
t.Errorf("Reply = %q, want %q", r.Reply, reply)
}
}
func TestResultWithEvents(t *testing.T) {
evt := PresenceEvent{payload: []byte(`{"type":"presence"}`)}
r := Result{Events: []Event{evt}}
if len(r.Events) != 1 {
t.Fatalf("len(Events) = %d, want 1", len(r.Events))
}
if r.Events[0].EventType() != MsgTypePresence {
t.Errorf("EventType() = %q, want %q", r.Events[0].EventType(), MsgTypePresence)
}
}
// ── EventType tests ─────────────────────────────────────────────────────────
func TestEventTypes(t *testing.T) {
tests := []struct {
name string
event Event
wantType string
}{
{"MessageSentChannelEvent", MessageSentChannelEvent{}, MsgTypeChatMessage},
{"MessageSentDMEvent", MessageSentDMEvent{}, MsgTypeChatMessage},
{"MessageEditedChannelEvent", MessageEditedChannelEvent{}, MsgTypeChatEdited},
{"MessageEditedDMEvent", MessageEditedDMEvent{}, MsgTypeChatEdited},
{"MessageDeletedChannelEvent", MessageDeletedChannelEvent{}, MsgTypeChatDeleted},
{"MessageDeletedDMEvent", MessageDeletedDMEvent{}, MsgTypeChatDeleted},
{"TypingChannelEvent", TypingChannelEvent{}, MsgTypeTyping},
{"TypingDMEvent", TypingDMEvent{}, MsgTypeTyping},
{"PresenceEvent", PresenceEvent{}, MsgTypePresence},
{"ReactionChannelEvent", ReactionChannelEvent{}, MsgTypeReactionUpdate},
{"ReactionDMEvent", ReactionDMEvent{}, MsgTypeReactionUpdate},
{"VoiceStateEvent", VoiceStateEvent{}, MsgTypeVoiceState},
{"VoiceLeaveEvent", VoiceLeaveEvent{}, MsgTypeVoiceLeaveBC},
{"VoiceE2EEAnnounceEvent", VoiceE2EEAnnounceEvent{}, MsgTypeVoiceE2EEAnnounceBC},
{"VoiceE2EEOfferGuardedEvent", VoiceE2EEOfferGuardedEvent{}, MsgTypeVoiceE2EEOfferRelay},
{"DMChannelOpenEvent", DMChannelOpenEvent{}, MsgTypeDMChannelOpen},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.event.EventType(); got != tt.wantType {
t.Errorf("EventType() = %q, want %q", got, tt.wantType)
}
})
}
}
// ── Routing interface tests ─────────────────────────────────────────────────
func TestChannelEventInterface(t *testing.T) {
events := []struct {
name string
event ChannelEvent
chID int64
}{
{"MessageSentChannelEvent", MessageSentChannelEvent{channelID: 10, payload: []byte("p")}, 10},
{"MessageEditedChannelEvent", MessageEditedChannelEvent{channelID: 20, payload: []byte("q")}, 20},
{"MessageDeletedChannelEvent", MessageDeletedChannelEvent{channelID: 30, payload: []byte("r")}, 30},
{"ReactionChannelEvent", ReactionChannelEvent{channelID: 40, payload: []byte("s")}, 40},
}
for _, tt := range events {
t.Run(tt.name, func(t *testing.T) {
if tt.event.ChannelID() != tt.chID {
t.Errorf("ChannelID() = %d, want %d", tt.event.ChannelID(), tt.chID)
}
if tt.event.Payload() == nil {
t.Error("Payload() should not be nil")
}
})
}
}
func TestExcludeSenderEventInterface(t *testing.T) {
evt := TypingChannelEvent{channelID: 5, excludeUserID: 42, payload: []byte("typing")}
var iface ExcludeSenderEvent = evt
if iface.ChannelID() != 5 {
t.Errorf("ChannelID() = %d, want 5", iface.ChannelID())
}
if iface.ExcludeUserID() != 42 {
t.Errorf("ExcludeUserID() = %d, want 42", iface.ExcludeUserID())
}
if string(iface.Payload()) != "typing" {
t.Errorf("Payload() = %q, want %q", iface.Payload(), "typing")
}
}
func TestSequencedDMEventInterface(t *testing.T) {
events := []struct {
name string
event SequencedDMEvent
chID int64
pIDs []int64
}{
{"MessageSentDMEvent", MessageSentDMEvent{channelID: 100, participantIDs: []int64{1, 2}, payload: []byte("m")}, 100, []int64{1, 2}},
{"MessageEditedDMEvent", MessageEditedDMEvent{channelID: 101, participantIDs: []int64{3, 4}, payload: []byte("e")}, 101, []int64{3, 4}},
{"MessageDeletedDMEvent", MessageDeletedDMEvent{channelID: 102, participantIDs: []int64{5, 6}, payload: []byte("d")}, 102, []int64{5, 6}},
{"ReactionDMEvent", ReactionDMEvent{channelID: 103, participantIDs: []int64{7, 8}, payload: []byte("r")}, 103, []int64{7, 8}},
}
for _, tt := range events {
t.Run(tt.name, func(t *testing.T) {
if tt.event.ChannelID() != tt.chID {
t.Errorf("ChannelID() = %d, want %d", tt.event.ChannelID(), tt.chID)
}
got := tt.event.ParticipantIDs()
if len(got) != len(tt.pIDs) {
t.Fatalf("ParticipantIDs() len = %d, want %d", len(got), len(tt.pIDs))
}
for i, id := range tt.pIDs {
if got[i] != id {
t.Errorf("ParticipantIDs()[%d] = %d, want %d", i, got[i], id)
}
}
if tt.event.Payload() == nil {
t.Error("Payload() should not be nil")
}
})
}
}
func TestSequencedDMEventDefensiveCopy(t *testing.T) {
orig := []int64{1, 2, 3}
evt := MessageSentDMEvent{participantIDs: orig}
got := evt.ParticipantIDs()
got[0] = 999
if evt.participantIDs[0] == 999 {
t.Error("ParticipantIDs() did not return a defensive copy")
}
}
func TestUserTargetedEventInterface(t *testing.T) {
events := []struct {
name string
event UserTargetedEvent
target int64
}{
{"TypingDMEvent", TypingDMEvent{targetUserID: 50, payload: []byte("t")}, 50},
{"DMChannelOpenEvent", DMChannelOpenEvent{targetUserID: 70, payload: []byte("d")}, 70},
}
for _, tt := range events {
t.Run(tt.name, func(t *testing.T) {
if tt.event.TargetUserID() != tt.target {
t.Errorf("TargetUserID() = %d, want %d", tt.event.TargetUserID(), tt.target)
}
if tt.event.Payload() == nil {
t.Error("Payload() should not be nil")
}
})
}
}
func TestBroadcastAllEventInterface(t *testing.T) {
events := []struct {
name string
event BroadcastAllEvent
}{
{"PresenceEvent", PresenceEvent{payload: []byte("p")}},
{"VoiceStateEvent", VoiceStateEvent{payload: []byte("vs")}},
{"VoiceLeaveEvent", VoiceLeaveEvent{payload: []byte("vl")}},
}
for _, tt := range events {
t.Run(tt.name, func(t *testing.T) {
if tt.event.Payload() == nil {
t.Error("Payload() should not be nil")
}
})
}
}
func TestVoiceChannelEventInterface(t *testing.T) {
evt := VoiceE2EEAnnounceEvent{voiceChannelID: 15, excludeUserID: 7, payload: []byte("ann")}
var iface VoiceChannelEvent = evt
if iface.VoiceChannelID() != 15 {
t.Errorf("VoiceChannelID() = %d, want 15", iface.VoiceChannelID())
}
if iface.ExcludeUserID() != 7 {
t.Errorf("ExcludeUserID() = %d, want 7", iface.ExcludeUserID())
}
if string(iface.Payload()) != "ann" {
t.Errorf("Payload() = %q, want %q", iface.Payload(), "ann")
}
}
func TestVoiceChannelGuardedEventInterface(t *testing.T) {
evt := VoiceE2EEOfferGuardedEvent{voiceChannelID: 20, targetUserID: 5, payload: []byte("offer")}
var iface VoiceChannelGuardedEvent = evt
if iface.VoiceChannelID() != 20 {
t.Errorf("VoiceChannelID() = %d, want 20", iface.VoiceChannelID())
}
if iface.TargetUserID() != 5 {
t.Errorf("TargetUserID() = %d, want 5", iface.TargetUserID())
}
if string(iface.Payload()) != "offer" {
t.Errorf("Payload() = %q, want %q", iface.Payload(), "offer")
}
}
// ── SequencedDMEvent checked before ChannelEvent ────────────────────────────
func TestDMEventsImplementBothInterfaces(t *testing.T) {
// SequencedDMEvent types also satisfy ChannelEvent (they have ChannelID + Payload).
// This test documents that EmitEvents must check SequencedDMEvent first.
dmEvents := []Event{
MessageSentDMEvent{channelID: 1, participantIDs: []int64{1, 2}, payload: []byte("x")},
MessageEditedDMEvent{channelID: 2, participantIDs: []int64{3, 4}, payload: []byte("y")},
MessageDeletedDMEvent{channelID: 3, participantIDs: []int64{5, 6}, payload: []byte("z")},
ReactionDMEvent{channelID: 4, participantIDs: []int64{7, 8}, payload: []byte("w")},
}
for _, evt := range dmEvents {
// Must satisfy SequencedDMEvent.
if _, ok := evt.(SequencedDMEvent); !ok {
t.Errorf("%T does not implement SequencedDMEvent", evt)
}
// Must also satisfy ChannelEvent (since they have ChannelID + Payload).
if _, ok := evt.(ChannelEvent); !ok {
t.Errorf("%T does not implement ChannelEvent", evt)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package ws
import (
"context"
"testing"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// newFocusTestDeps creates an in-memory DB with a user (role=Owner, id=1) and
// a text channel, returning deps and the IDs needed for assertions.
func newFocusTestDeps(t *testing.T) (PresenceDeps, int64, int64) {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
userID, err := database.CreateUser("focuser", "hash", 1) // Owner role
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
chID, err := database.CreateChannel("focus-chan", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
perms := permissions.NewChecker(database)
deps := PresenceDeps{DB: database, Limiter: nil, Permissions: perms}
return deps, userID, chID
}
func TestChannelFocusV2_HappyPath_SetsChannelID(t *testing.T) {
deps, userID, chID := newFocusTestDeps(t)
cmd := ChannelFocusCmd{userID: userID, channelID: chID}
info := ClientInfo{UserID: userID, Username: "focuser"}
result := handleChannelFocusV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.SetChannelID == nil {
t.Fatal("expected SetChannelID to be set")
}
if *result.SetChannelID != chID {
t.Errorf("SetChannelID = %d, want %d", *result.SetChannelID, chID)
}
}
func TestChannelFocusV2_InvalidChannelID_SilentDrop(t *testing.T) {
deps, userID, _ := newFocusTestDeps(t)
cmd := ChannelFocusCmd{userID: userID, channelID: 0}
info := ClientInfo{UserID: userID}
result := handleChannelFocusV2(context.Background(), cmd, info, deps)
// channelID <= 0 → silently dropped (no error, no SetChannelID).
if result.Error != nil {
t.Errorf("expected nil error for invalid channel_id, got %v", result.Error)
}
if result.SetChannelID != nil {
t.Errorf("expected nil SetChannelID for invalid channel_id, got %d", *result.SetChannelID)
}
}
func TestChannelFocusV2_ChannelNotFound_SilentDrop(t *testing.T) {
deps, userID, _ := newFocusTestDeps(t)
cmd := ChannelFocusCmd{userID: userID, channelID: 99999}
info := ClientInfo{UserID: userID}
result := handleChannelFocusV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Errorf("expected nil error for missing channel, got %v", result.Error)
}
if result.SetChannelID != nil {
t.Errorf("expected nil SetChannelID for missing channel, got %d", *result.SetChannelID)
}
}
func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) {
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
// Use Member role (id=4) and create a channel with a deny override.
userID, _ := database.CreateUser("noperm", "hash", 4)
chID, _ := database.CreateChannel("restricted", "text", "", "", 0)
// Deny READ_MESSAGES for Member role on this channel via raw SQL.
_, err = database.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, ?)`,
chID, permissions.ReadMessages,
)
if err != nil {
t.Fatalf("INSERT channel_overrides: %v", err)
}
perms := permissions.NewChecker(database)
deps := PresenceDeps{DB: database, Limiter: nil, Permissions: perms}
cmd := ChannelFocusCmd{userID: userID, channelID: chID}
info := ClientInfo{UserID: userID, Username: "noperm"}
result := handleChannelFocusV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected FORBIDDEN error for denied permission")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeForbidden {
t.Errorf("expected code %q, got %q", ErrCodeForbidden, ce.Code)
}
}
func TestChannelFocusV2_NoEvents(t *testing.T) {
deps, userID, chID := newFocusTestDeps(t)
cmd := ChannelFocusCmd{userID: userID, channelID: chID}
info := ClientInfo{UserID: userID, Username: "focuser"}
result := handleChannelFocusV2(context.Background(), cmd, info, deps)
if len(result.Events) != 0 {
t.Errorf("expected no events, got %d", len(result.Events))
}
}
+63
View File
@@ -0,0 +1,63 @@
package ws
import (
"context"
"encoding/json"
"testing"
"github.com/owncord/server/auth"
)
func TestPingV2_HappyPath_ReturnsPongReply(t *testing.T) {
limiter := auth.NewRateLimiter()
deps := PingDeps{Limiter: limiter}
cmd := PingCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice"}
result := handlePingV2(context.Background(), cmd, info, deps)
if result.Reply == nil {
t.Fatal("expected pong reply, got nil")
}
var reply map[string]any
if err := json.Unmarshal(result.Reply, &reply); err != nil {
t.Fatalf("failed to unmarshal reply: %v", err)
}
if reply["type"] != MsgTypePong {
t.Errorf("expected type %q, got %q", MsgTypePong, reply["type"])
}
}
func TestPingV2_RateLimited_ReturnsEmpty(t *testing.T) {
limiter := auth.NewRateLimiter()
deps := PingDeps{Limiter: limiter}
cmd := PingCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice"}
// Exhaust the rate limit (2 per second).
_ = handlePingV2(context.Background(), cmd, info, deps)
_ = handlePingV2(context.Background(), cmd, info, deps)
// Third call should be rate limited.
result := handlePingV2(context.Background(), cmd, info, deps)
if result.Reply != nil {
t.Errorf("expected nil reply when rate limited, got %s", result.Reply)
}
if result.Error != nil {
t.Errorf("expected nil error when rate limited, got %v", result.Error)
}
}
func TestPingV2_NoEvents(t *testing.T) {
limiter := auth.NewRateLimiter()
deps := PingDeps{Limiter: limiter}
cmd := PingCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice"}
result := handlePingV2(context.Background(), cmd, info, deps)
if len(result.Events) != 0 {
t.Errorf("expected no events, got %d", len(result.Events))
}
}
@@ -0,0 +1,205 @@
package ws
import (
"context"
"encoding/base64"
"strings"
"testing"
)
var (
validEncKey = base64.StdEncoding.EncodeToString(make([]byte, 48))
validIV = base64.StdEncoding.EncodeToString(make([]byte, 12))
)
func offerDeps(isHolder bool) VoiceDeps {
return VoiceDeps{
KeyHolder: &mockKeyHolder{isHolder: isHolder},
}
}
func TestVoiceE2EEOfferV2_HappyPath(t *testing.T) {
deps := offerDeps(true)
cmd := VoiceE2EEOfferCmd{
userID: 1,
targetUserID: 2,
encryptedKey: validEncKey,
iv: validIV,
}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if len(result.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(result.Events))
}
// Should be a VoiceChannelGuardedEvent targeting user 2.
evt, ok := result.Events[0].(VoiceChannelGuardedEvent)
if !ok {
t.Fatalf("expected VoiceChannelGuardedEvent, got %T", result.Events[0])
}
if evt.TargetUserID() != 2 {
t.Errorf("expected target user 2, got %d", evt.TargetUserID())
}
if evt.VoiceChannelID() != 100 {
t.Errorf("expected voice channel 100, got %d", evt.VoiceChannelID())
}
}
func TestVoiceE2EEOfferV2_NotInVoiceChannel(t *testing.T) {
deps := offerDeps(true)
cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV}
info := ClientInfo{UserID: 1, VoiceChannelID: 0}
result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for not in voice channel")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeVoiceError {
t.Errorf("expected code %q, got %q", ErrCodeVoiceError, ce.Code)
}
}
func TestVoiceE2EEOfferV2_EmptyFields(t *testing.T) {
deps := offerDeps(true)
tests := []struct {
name string
cmd VoiceE2EEOfferCmd
}{
{"empty target", VoiceE2EEOfferCmd{userID: 1, targetUserID: 0, encryptedKey: validEncKey, iv: validIV}},
{"empty key", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: "", iv: validIV}},
{"empty iv", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), tt.cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for empty field")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
})
}
}
func TestVoiceE2EEOfferV2_OversizedFields(t *testing.T) {
deps := offerDeps(true)
tests := []struct {
name string
cmd VoiceE2EEOfferCmd
}{
{"key too large", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: strings.Repeat("A", 1025), iv: validIV}},
{"iv too large", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: strings.Repeat("A", 129)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), tt.cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for oversized field")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
})
}
}
func TestVoiceE2EEOfferV2_InvalidBase64(t *testing.T) {
deps := offerDeps(true)
tests := []struct {
name string
cmd VoiceE2EEOfferCmd
}{
{"bad key", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: "not-base64!!!", iv: validIV}},
{"bad iv", VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: "not-base64!!!"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), tt.cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for invalid base64")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
})
}
}
func TestVoiceE2EEOfferV2_NotKeyHolder(t *testing.T) {
deps := offerDeps(false)
cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for non-key-holder")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeNotKeyHolder {
t.Errorf("expected code %q, got %q", ErrCodeNotKeyHolder, ce.Code)
}
}
func TestVoiceE2EEOfferV2_NilKeyHolder_ReturnsInternal(t *testing.T) {
deps := VoiceDeps{KeyHolder: nil}
cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for nil KeyHolder")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Errorf("expected code %q, got %q", ErrCodeInternal, ce.Code)
}
}
func TestVoiceE2EEOfferV2_NoReply(t *testing.T) {
deps := offerDeps(true)
cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps)
if result.Reply != nil {
t.Errorf("expected no reply, got %s", result.Reply)
}
}
+135
View File
@@ -0,0 +1,135 @@
package ws
import (
"context"
"encoding/base64"
"strings"
"testing"
)
// validB64Key is a valid base64-encoded P-256 public key (65 bytes uncompressed).
var validB64Key = base64.StdEncoding.EncodeToString(make([]byte, 65))
func TestVoiceE2EEAnnounceV2_HappyPath(t *testing.T) {
deps := VoiceDeps{}
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
// Should set the E2EE public key on the client via Result.
if result.SetE2EEPubKey == nil {
t.Fatal("expected SetE2EEPubKey to be set")
}
if *result.SetE2EEPubKey != validB64Key {
t.Errorf("expected SetE2EEPubKey %q, got %q", validB64Key, *result.SetE2EEPubKey)
}
// Should emit a VoiceE2EEAnnounceEvent to the voice channel.
if len(result.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(result.Events))
}
evt, ok := result.Events[0].(VoiceChannelEvent)
if !ok {
t.Fatalf("expected VoiceChannelEvent, got %T", result.Events[0])
}
if evt.VoiceChannelID() != 100 {
t.Errorf("expected voice channel 100, got %d", evt.VoiceChannelID())
}
if evt.ExcludeUserID() != 1 {
t.Errorf("expected exclude user 1, got %d", evt.ExcludeUserID())
}
}
func TestVoiceE2EEAnnounceV2_NotInVoiceChannel(t *testing.T) {
deps := VoiceDeps{}
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key}
info := ClientInfo{UserID: 1, VoiceChannelID: 0}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for not in voice channel")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeVoiceError {
t.Errorf("expected code %q, got %q", ErrCodeVoiceError, ce.Code)
}
}
func TestVoiceE2EEAnnounceV2_EmptyPublicKey(t *testing.T) {
deps := VoiceDeps{}
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: ""}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for empty public_key")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
}
func TestVoiceE2EEAnnounceV2_PublicKeyTooLarge(t *testing.T) {
deps := VoiceDeps{}
largeKey := strings.Repeat("A", 129)
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: largeKey}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for oversized public_key")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
}
func TestVoiceE2EEAnnounceV2_InvalidBase64(t *testing.T) {
deps := VoiceDeps{}
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: "not-valid-base64!!!"}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for invalid base64")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadPayload {
t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code)
}
}
func TestVoiceE2EEAnnounceV2_NoReply(t *testing.T) {
deps := VoiceDeps{}
cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key}
info := ClientInfo{UserID: 1, VoiceChannelID: 100}
result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps)
if result.Reply != nil {
t.Errorf("expected no reply, got %s", result.Reply)
}
}
+243
View File
@@ -0,0 +1,243 @@
package ws
import (
"context"
"encoding/json"
"testing"
"github.com/owncord/server/auth"
)
// ── mocks ──────────────────────────────────────────────────────────────────────
type mockTokenGen struct {
token string
err error
url string
}
func (m *mockTokenGen) GenerateToken(
_ int64, _ string, _ int64, _ string,
_, _, _, _ bool,
) (string, error) {
return m.token, m.err
}
func (m *mockTokenGen) URL() string { return m.url }
type mockKeyHolder struct {
isHolder bool
}
func (m *mockKeyHolder) IsVoiceKeyHolder(_, _ int64) bool { return m.isHolder }
// ── tests ──────────────────────────────────────────────────────────────────────
func tokenRefreshDeps() VoiceDeps {
return VoiceDeps{
Limiter: auth.NewRateLimiter(),
TokenGen: &mockTokenGen{token: "jwt-test-token", url: "ws://lk:7880"},
KeyHolder: &mockKeyHolder{isHolder: true},
}
}
func TestVoiceTokenRefreshV2_HappyPath(t *testing.T) {
deps := tokenRefreshDeps()
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{
UserID: 1,
Username: "alice",
VoiceChannelID: 100,
VoiceJoinToken: "join-tok-123",
}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.Reply == nil {
t.Fatal("expected a reply with voice token")
}
var reply map[string]any
if err := json.Unmarshal(result.Reply, &reply); err != nil {
t.Fatalf("failed to unmarshal reply: %v", err)
}
if reply["type"] != MsgTypeVoiceToken {
t.Errorf("expected type %q, got %q", MsgTypeVoiceToken, reply["type"])
}
}
func TestVoiceTokenRefreshV2_NotInVoice(t *testing.T) {
deps := tokenRefreshDeps()
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, VoiceChannelID: 0}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for not in voice")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeBadRequest {
t.Errorf("expected code %q, got %q", ErrCodeBadRequest, ce.Code)
}
}
func TestVoiceTokenRefreshV2_RateLimited(t *testing.T) {
deps := tokenRefreshDeps()
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100, VoiceJoinToken: "t"}
// Exhaust the rate limit (1 per 60s).
_ = handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected rate limit error")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeRateLimited {
t.Errorf("expected code %q, got %q", ErrCodeRateLimited, ce.Code)
}
}
func TestVoiceTokenRefreshV2_TokenGenNil(t *testing.T) {
deps := tokenRefreshDeps()
deps.TokenGen = nil
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, VoiceChannelID: 100, VoiceJoinToken: "t"}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error for nil TokenGen")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Errorf("expected code %q, got %q", ErrCodeInternal, ce.Code)
}
}
func TestVoiceTokenRefreshV2_GenerateTokenError(t *testing.T) {
deps := tokenRefreshDeps()
deps.TokenGen = &mockTokenGen{err: context.DeadlineExceeded, url: "ws://lk:7880"}
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100, VoiceJoinToken: "t"}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error == nil {
t.Fatal("expected error from GenerateToken failure")
}
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Errorf("expected code %q, got %q", ErrCodeInternal, ce.Code)
}
}
func TestVoiceTokenRefreshV2_IsKeyHolderReflectedInReply(t *testing.T) {
deps := tokenRefreshDeps()
deps.KeyHolder = &mockKeyHolder{isHolder: false}
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100, VoiceJoinToken: "t"}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
var reply struct {
Payload struct {
IsKeyHolder bool `json:"is_key_holder"`
} `json:"payload"`
}
if err := json.Unmarshal(result.Reply, &reply); err != nil {
t.Fatalf("failed to unmarshal reply: %v", err)
}
if reply.Payload.IsKeyHolder != false {
t.Error("expected is_key_holder=false in reply")
}
}
func TestVoiceTokenRefreshV2_NoEvents(t *testing.T) {
deps := tokenRefreshDeps()
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100, VoiceJoinToken: "t"}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if len(result.Events) != 0 {
t.Errorf("expected no events, got %d", len(result.Events))
}
}
func TestVoiceTokenRefreshV2_PermissionsPassedToTokenGen(t *testing.T) {
// Use a capturing mock to verify permissions are forwarded.
captureMock := &capturingTokenGen{token: "jwt", url: "ws://lk"}
deps := tokenRefreshDeps()
deps.TokenGen = captureMock
// No Permissions or DB set → hasPerm returns false for all.
deps.Permissions = nil
deps.DB = nil
cmd := VoiceTokenRefreshCmd{userID: 1}
info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100, VoiceJoinToken: "t"}
result := handleVoiceTokenRefreshV2(context.Background(), cmd, info, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
// Without permissions/DB, all permission checks return false.
if captureMock.canPublish {
t.Error("expected canPublish=false without permissions")
}
if captureMock.canVideo {
t.Error("expected canVideo=false without permissions")
}
if captureMock.canScreenShare {
t.Error("expected canScreenShare=false without permissions")
}
// canSubscribe should always be true.
if !captureMock.canSubscribe {
t.Error("expected canSubscribe=true always")
}
}
// capturingTokenGen records the arguments passed to GenerateToken.
type capturingTokenGen struct {
token string
url string
canPublish bool
canSubscribe bool
canVideo bool
canScreenShare bool
}
func (m *capturingTokenGen) GenerateToken(
_ int64, _ string, _ int64, _ string,
canPublish, canSubscribe, canVideo, canScreenShare bool,
) (string, error) {
m.canPublish = canPublish
m.canSubscribe = canSubscribe
m.canVideo = canVideo
m.canScreenShare = canScreenShare
return m.token, nil
}
func (m *capturingTokenGen) URL() string { return m.url }
+85 -7
View File
@@ -93,22 +93,100 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
c.invalidCount = 0
c.mu.Unlock()
// Cap client-controlled fields before logging to prevent log injection
// and unbounded log entries.
msgType := env.Type
if len(msgType) > 64 {
msgType = msgType[:64]
}
reqID := env.ID
if len(reqID) > 64 {
reqID = reqID[:64]
}
// Request-scoped logger with correlation context.
reqLog := slog.With(
"user_id", c.userID,
"msg_type", env.Type,
"req_id", env.ID,
"msg_type", msgType,
"req_id", reqID,
)
reqLog.Debug("ws ← client message")
// ── V2 dispatch (strangler fig) ──────────────────────────────────────
// Only attempt V2 parsing+dispatch if a V2 handler is registered for
// this type. This prevents the stricter V2 parser from rejecting
// payloads that V1 handlers handle leniently.
if h.registry.hasV2(env.Type) {
if ctor, ok := getCommandConstructor(env.Type); ok {
cmd, parseErr := ctor(c.userID, env.ID, env.Payload)
if parseErr != nil {
reqLog.Warn("ws command parse error", "err", parseErr)
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid payload"))
return
}
var username string
var avatar *string
if c.user != nil {
username = c.user.Username
avatar = c.user.Avatar
}
voiceChID, voiceJoinTok := c.getVoiceState()
info := ClientInfo{
UserID: c.userID,
Username: username,
Avatar: avatar,
RoleName: c.roleName,
ReqID: env.ID,
VoiceChannelID: voiceChID,
VoiceJoinToken: voiceJoinTok,
}
result, dispatched := h.registry.DispatchV2(c.ctx, cmd, info)
if !dispatched {
reqLog.Error("ws V2 handler registered but DispatchV2 returned false", "type", env.Type)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
return
}
if result.Error != nil {
if ce, ok := result.Error.(ClientError); ok {
c.sendMsg(buildErrorMsg(ce.Code, ce.Message))
} else {
reqLog.Error("ws handler internal error", "err", result.Error)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
}
return
}
// Apply client state mutations.
if result.SetChannelID != nil {
c.mu.Lock()
c.channelID = *result.SetChannelID
c.mu.Unlock()
}
if result.SetE2EEPubKey != nil {
c.setE2EEPubKey(*result.SetE2EEPubKey)
}
if result.SetVoiceJoinToken != nil {
chID := c.getVoiceChID()
if chID != 0 {
c.setVoiceState(chID, *result.SetVoiceJoinToken)
}
}
if result.Reply != nil {
c.sendMsg(result.Reply)
}
if len(result.Events) > 0 {
h.EmitEvents(result.Events)
}
return
}
}
// ── End V2 dispatch ──────────────────────────────────────────────────
if !h.registry.Dispatch(c.ctx, env.Type, h, c, env.ID, env.Payload) {
reqLog.Warn("ws handleMessage unknown type")
truncType := env.Type
if len(truncType) > 64 {
truncType = truncType[:64]
}
c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", truncType)))
c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", msgType)))
}
}
+243 -282
View File
@@ -2,399 +2,360 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// registerChatHandlers registers all chat-related message handlers.
func registerChatHandlers(r *HandlerRegistry) {
r.Register(MsgTypeChatSend, func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
h.handleChatSend(ctx, c, reqID, payload)
})
r.Register(MsgTypeChatEdit, func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
h.handleChatEdit(ctx, c, reqID, payload)
})
r.Register(MsgTypeChatDelete, func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
h.handleChatDelete(ctx, c, reqID, payload)
})
// registerChatHandlers registers all chat-related V2 message handlers.
func registerChatHandlers(r *HandlerRegistry, deps ChatDeps) {
r.RegisterV2(MsgTypeChatSend, handleChatSendV2, deps)
r.RegisterV2(MsgTypeChatEdit, handleChatEditV2, deps)
r.RegisterV2(MsgTypeChatDelete, handleChatDeleteV2, deps)
}
type chatSendPayload struct {
ChannelID json.Number `json:"channel_id"`
Content string `json:"content"`
ReplyTo *int64 `json:"reply_to"`
Attachments []string `json:"attachments"`
}
// handleChatSendV2 processes a chat_send command.
func handleChatSendV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps)
sendCmd := cmd.(ChatSendCmd)
userID := info.UserID
channelID := sendCmd.ChannelID()
// handleChatSend processes a chat_send message.
func (h *Hub) handleChatSend(_ context.Context, c *Client, reqID string, payload json.RawMessage) {
ratKey := fmt.Sprintf("chat:%d", c.userID)
if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) {
c.sendMsg(buildRateLimitError("too many messages", chatWindow.Seconds()))
return
// Rate limit.
ratKey := fmt.Sprintf("chat:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many messages"}}
}
var p chatSendPayload
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_send payload"))
return
}
channelID, err := p.ChannelID.Int64()
if err != nil || channelID <= 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer"))
return
if channelID <= 0 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "channel_id must be a positive integer"}}
}
ch, err := h.db.GetChannel(channelID)
ch, err := d.DB.GetChannel(channelID)
if err != nil || ch == nil {
c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found"))
return
return Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}}
}
isDM := ch.Type == "dm"
if !h.checkChatSendPermission(c, channelID, isDM) {
return
}
if !h.checkSlowMode(c, ch, channelID, isDM) {
return
// Permission check.
if r := chatSendPermCheck(d, userID, channelID, isDM); r != nil {
return *r
}
content, ok := h.validateChatContent(c, p.Content, p.Attachments)
if !ok {
return
}
if !isDM && len(p.Attachments) > 0 {
if !h.requireChannelPerm(c, channelID, permissions.AttachFiles, "ATTACH_FILES") {
return
// Slow mode.
if !isDM && ch.SlowMode > 0 && !hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ManageMessages) {
slowKey := fmt.Sprintf("slow:%d:%d", userID, channelID)
if d.Limiter != nil && !d.Limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
return Result{Error: ClientError{Code: ErrCodeSlowMode, Message: fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)}}
}
}
msgID, attachments, ok := h.persistChatMessage(c, channelID, content, p.ReplyTo, p.Attachments)
if !ok {
return
// Validate content — check raw length before sanitizing to prevent
// CPU/memory amplification from huge payloads hitting bluemonday.
rawContent := sendCmd.Content()
attachmentIDs := sendCmd.Attachments()
if len(rawContent) > maxMessageLen*4 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content exceeds maximum length of 4000 characters"}}
}
content := sanitizer.Sanitize(rawContent)
if content == "" && len(attachmentIDs) == 0 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content cannot be empty"}}
}
if len([]rune(content)) > maxMessageLen {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content exceeds maximum length of 4000 characters"}}
}
msg, err := h.db.GetMessage(msgID)
// Attachment permission.
if !isDM && len(attachmentIDs) > 0 {
if r := requirePerm(d.DB, d.Permissions, userID, channelID, permissions.AttachFiles, "ATTACH_FILES"); r != nil {
return *r
}
}
// Persist message.
msgID, err := d.DB.CreateMessage(channelID, userID, content, sendCmd.ReplyTo())
if err != nil {
slog.Error("ws handleChatSendV2 CreateMessage", "err", err)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to save message"}}
}
// Link attachments.
var attData []map[string]any
if len(attachmentIDs) > 0 {
linked, linkErr := d.DB.LinkAttachmentsToMessage(msgID, attachmentIDs)
if linkErr != nil {
slog.Error("ws handleChatSendV2 LinkAttachments", "err", linkErr, "msg_id", msgID)
if delErr := d.DB.DeleteMessage(msgID, userID, true); delErr != nil {
slog.Error("ws handleChatSendV2 DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID)
}
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to send message with attachments"}}
}
if linked > 0 {
attMap, attErr := d.DB.GetAttachmentsByMessageIDs([]int64{msgID})
if attErr != nil {
slog.Error("ws handleChatSendV2 GetAttachments", "err", attErr)
} else {
for _, ai := range attMap[msgID] {
attData = append(attData, map[string]any{
"id": ai.ID,
"filename": ai.Filename,
"size": ai.Size,
"mime": ai.Mime,
"url": ai.URL,
})
}
}
}
}
// Fetch message for timestamp.
msg, err := d.DB.GetMessage(msgID)
if err != nil || msg == nil {
slog.Error("ws handleChatSend GetMessage after create", "err", err)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to retrieve message"))
return
slog.Error("ws handleChatSendV2 GetMessage after create", "err", err)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to retrieve message"}}
}
var username string
var avatar *string
if c.user != nil {
username = c.user.Username
avatar = c.user.Avatar
slog.Debug("message sent", "user", info.Username, "channel_id", channelID, "msg_id", msgID)
reply := buildChatSendOK(info.ReqID, msgID, msg.Timestamp)
broadcast := buildChatMessage(msgID, channelID, userID, info.Username, info.Avatar, info.RoleName, content, msg.Timestamp, sendCmd.ReplyTo(), attData)
if !isDM {
return Result{
Reply: reply,
Events: []Event{MessageSentChannelEvent{channelID: channelID, payload: broadcast}},
}
}
slog.Debug("message sent", "user", username, "channel_id", channelID, "msg_id", msgID)
c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp))
// DM path: open DM for recipients, send dm_channel_open, then sequenced message.
participantIDs, pErr := d.DB.GetDMParticipantIDs(channelID)
if pErr != nil {
slog.Error("ws handleChatSendV2 GetDMParticipantIDs", "err", pErr, "channel_id", channelID)
// Message is saved; return the ACK but skip broadcast.
return Result{Reply: reply}
}
broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, c.roleName, content, msg.Timestamp, p.ReplyTo, attachments)
h.broadcastChatMessage(c, channelID, isDM, broadcast)
var events []Event
sender, _ := d.DB.GetUserByID(userID)
for _, pid := range participantIDs {
if pid == userID {
continue
}
if openErr := d.DB.OpenDM(pid, channelID); openErr != nil {
slog.Error("ws handleChatSendV2 OpenDM", "err", openErr, "recipient_id", pid, "channel_id", channelID)
continue
}
if sender != nil {
events = append(events, DMChannelOpenEvent{
targetUserID: pid,
payload: buildDMChannelOpen(channelID, sender),
})
}
}
events = append(events, MessageSentDMEvent{
channelID: channelID,
participantIDs: participantIDs,
payload: broadcast,
})
return Result{Reply: reply, Events: events}
}
func (h *Hub) checkChatSendPermission(c *Client, channelID int64, isDM bool) bool {
// chatSendPermCheck validates send permission for DM and non-DM channels.
func chatSendPermCheck(d ChatDeps, userID, channelID int64, isDM bool) *Result {
if isDM {
ok, dmErr := h.db.IsDMParticipant(c.userID, channelID)
ok, dmErr := d.DB.IsDMParticipant(userID, channelID)
if dmErr != nil {
slog.Error("ws handleChatSend IsDMParticipant", "err", dmErr)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check DM participation"))
return false
slog.Error("ws chatSendPermCheck IsDMParticipant", "err", dmErr)
r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check DM participation"}}
return &r
}
if !ok {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "you are not a participant in this DM"))
return false
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "you are not a participant in this DM"}}
return &r
}
// Check if either DM participant has blocked the other.
recipient, recErr := h.db.GetDMRecipient(channelID, c.userID)
recipient, recErr := d.DB.GetDMRecipient(channelID, userID)
if recErr == nil && recipient != nil {
blocked, blkErr := h.db.IsEitherBlocked(c.userID, recipient.ID)
blocked, blkErr := d.DB.IsEitherBlocked(userID, recipient.ID)
if blkErr != nil {
slog.Error("ws checkChatSendPermission IsEitherBlocked", "err", blkErr)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check block status"))
return false
slog.Error("ws chatSendPermCheck IsEitherBlocked", "err", blkErr)
r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check block status"}}
return &r
}
if blocked {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot send messages — user is blocked"))
return false
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot send messages — user is blocked"}}
return &r
}
}
return true
return nil
}
return h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES")
return requirePerm(d.DB, d.Permissions, userID, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES")
}
func (h *Hub) checkSlowMode(c *Client, ch *db.Channel, channelID int64, isDM bool) bool {
if isDM || ch.SlowMode <= 0 || h.hasChannelPerm(c, channelID, permissions.ManageMessages) {
return true
}
slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID)
if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
c.sendMsg(buildErrorMsg(ErrCodeSlowMode, fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)))
return false
}
return true
}
// handleChatEditV2 processes a chat_edit command.
func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps)
editCmd := cmd.(ChatEditCmd)
userID := info.UserID
msgID := editCmd.MessageID()
func (h *Hub) validateChatContent(c *Client, raw string, attachments []string) (string, bool) {
content := sanitizer.Sanitize(raw)
if content == "" && len(attachments) == 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content cannot be empty"))
return "", false
}
if len([]rune(content)) > maxMessageLen {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content exceeds maximum length of 4000 characters"))
return "", false
}
return content, true
}
func (h *Hub) persistChatMessage(c *Client, channelID int64, content string, replyTo *int64, attIDs []string) (int64, []map[string]any, bool) {
msgID, err := h.db.CreateMessage(channelID, c.userID, content, replyTo)
if err != nil {
slog.Error("ws handleChatSend CreateMessage", "err", err)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to save message"))
return 0, nil, false
// Rate limit.
ratKey := fmt.Sprintf("chat_edit:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many edits"}}
}
attachments, ok := h.linkAttachments(c, msgID, attIDs)
if !ok {
return 0, nil, false
}
return msgID, attachments, true
}
func (h *Hub) linkAttachments(c *Client, msgID int64, attIDs []string) ([]map[string]any, bool) {
if len(attIDs) == 0 {
return nil, true
if msgID <= 0 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}}
}
linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, attIDs)
if linkErr != nil {
slog.Error("ws handleChatSend LinkAttachments", "err", linkErr, "msg_id", msgID)
if delErr := h.db.DeleteMessage(msgID, c.userID, true); delErr != nil {
slog.Error("ws handleChatSend DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID)
}
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to send message with attachments"))
return nil, false
// Validate content — check raw length before sanitizing to prevent
// CPU/memory amplification from huge payloads hitting bluemonday.
rawContent := editCmd.Content()
if len(rawContent) > maxMessageLen*4 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message too long"}}
}
if linked == 0 {
return nil, true
}
attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID})
if attErr != nil {
slog.Error("ws handleChatSend GetAttachments", "err", attErr)
return nil, true
}
var attachments []map[string]any
for _, ai := range attMap[msgID] {
attachments = append(attachments, map[string]any{
"id": ai.ID,
"filename": ai.Filename,
"size": ai.Size,
"mime": ai.Mime,
"url": ai.URL,
})
}
return attachments, true
}
func (h *Hub) broadcastChatMessage(c *Client, channelID int64, isDM bool, broadcast []byte) {
if !isDM {
h.BroadcastToChannel(channelID, broadcast)
return
}
participantIDs, pErr := h.db.GetDMParticipantIDs(channelID)
if pErr != nil {
slog.Error("ws handleChatSend GetDMParticipantIDs", "err", pErr, "channel_id", channelID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "message saved but delivery failed — please retry"))
return
}
for _, pid := range participantIDs {
if pid == c.userID {
continue
}
if openErr := h.db.OpenDM(pid, channelID); openErr != nil {
slog.Error("ws handleChatSend OpenDM", "err", openErr,
"recipient_id", pid, "channel_id", channelID)
continue
}
if c.user != nil {
h.SendToUser(pid, buildDMChannelOpen(channelID, c.user))
}
}
h.sendSequencedToUsers(channelID, participantIDs, broadcast)
}
// handleChatEdit processes a chat_edit message.
func (h *Hub) handleChatEdit(_ context.Context, c *Client, _ string, payload json.RawMessage) {
ratKey := fmt.Sprintf("chat_edit:%d", c.userID)
if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) {
c.sendMsg(buildRateLimitError("too many edits", chatWindow.Seconds()))
return
}
var p struct {
MessageID json.Number `json:"message_id"`
Content string `json:"content"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_edit payload"))
return
}
msgID, err := p.MessageID.Int64()
if err != nil || msgID <= 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
return
}
content := sanitizer.Sanitize(p.Content)
content := sanitizer.Sanitize(rawContent)
if content == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "content cannot be empty"))
return
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "content cannot be empty"}}
}
if len([]rune(content)) > maxMessageLen {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message too long"))
return
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message too long"}}
}
// Fetch message first to get the channel ID for the permission check.
// Use an opaque error to prevent message-ID enumeration (IDOR).
msg, err := h.db.GetMessage(msgID)
// Fetch message (opaque error to prevent IDOR).
msg, err := d.DB.GetMessage(msgID)
if err != nil || msg == nil {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}}
}
// BUG-126: Reject edits on soft-deleted messages.
if msg.Deleted {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}}
}
// Check channel type for DM-aware permission handling.
editCh, chErr := h.db.GetChannel(msg.ChannelID)
// Channel type for DM-aware permissions.
editCh, chErr := d.DB.GetChannel(msg.ChannelID)
editIsDM := chErr == nil && editCh != nil && editCh.Type == "dm"
if editIsDM {
ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID)
ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID)
if dmErr != nil || !ok {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}}
}
} else if !h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages) {
// Re-check that the user still has SendMessages permission on this channel.
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
return
} else if !hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.SendMessages) {
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}}
}
// EditMessage checks ownership internally.
if err := h.db.EditMessage(msgID, c.userID, content); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
return
if err := d.DB.EditMessage(msgID, userID, content); err != nil {
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}}
}
// Re-fetch to get the updated edited_at timestamp.
msg, err = h.db.GetMessage(msgID)
// Re-fetch for updated edited_at timestamp.
msg, err = d.DB.GetMessage(msgID)
if err != nil || msg == nil {
slog.Error("ws handleChatEdit GetMessage after edit", "err", err, "msg_id", msgID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "edit saved but broadcast failed"))
return
slog.Error("ws handleChatEditV2 GetMessage after edit", "err", err, "msg_id", msgID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "edit saved but broadcast failed"}}
}
editedAt := ""
if msg.EditedAt != nil {
editedAt = *msg.EditedAt
}
slog.Debug("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID)
slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID)
editedMsg := buildChatEdited(msgID, msg.ChannelID, content, editedAt)
editedPayload := buildChatEdited(msgID, msg.ChannelID, content, editedAt)
if editIsDM {
h.broadcastToDMParticipants(msg.ChannelID, editedMsg)
} else {
h.BroadcastToChannel(msg.ChannelID, editedMsg)
participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID)
if pErr != nil {
slog.Error("handleChatEditV2 GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
return Result{}
}
return Result{Events: []Event{MessageEditedDMEvent{
channelID: msg.ChannelID,
participantIDs: participantIDs,
payload: editedPayload,
}}}
}
return Result{Events: []Event{MessageEditedChannelEvent{
channelID: msg.ChannelID,
payload: editedPayload,
}}}
}
// handleChatDelete processes a chat_delete message.
func (h *Hub) handleChatDelete(_ context.Context, c *Client, _ string, payload json.RawMessage) {
ratKey := fmt.Sprintf("chat_delete:%d", c.userID)
if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) {
c.sendMsg(buildRateLimitError("too many deletes", chatWindow.Seconds()))
return
// handleChatDeleteV2 processes a chat_delete command.
func handleChatDeleteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps)
deleteCmd := cmd.(ChatDeleteCmd)
userID := info.UserID
msgID := deleteCmd.MessageID()
// Rate limit.
ratKey := fmt.Sprintf("chat_delete:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many deletes"}}
}
var p struct {
MessageID json.Number `json:"message_id"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_delete payload"))
return
}
msgID, err := p.MessageID.Int64()
if err != nil || msgID <= 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
return
if msgID <= 0 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}}
}
// Use an opaque error to prevent message-ID enumeration (IDOR).
msg, err := h.db.GetMessage(msgID)
// Fetch message (opaque error to prevent IDOR).
msg, err := d.DB.GetMessage(msgID)
if err != nil || msg == nil {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}}
}
// Check channel type for DM-aware permission handling.
delCh, chErr := h.db.GetChannel(msg.ChannelID)
// Channel type for DM-aware permissions.
delCh, chErr := d.DB.GetChannel(msg.ChannelID)
delIsDM := chErr == nil && delCh != nil && delCh.Type == "dm"
if delIsDM {
ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID)
ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID)
if dmErr != nil || !ok {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}}
}
} else {
// Mod override: ManageMessages allows deleting any message.
// Own-message delete requires SendMessages (a muted user cannot delete).
isMsgOwner := msg.UserID == c.userID
canManage := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages)
canDelete := canManage || (isMsgOwner && h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages))
isMsgOwner := msg.UserID == userID
canManage := hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.ManageMessages)
canDelete := canManage || (isMsgOwner && hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.SendMessages))
if !canDelete {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message"))
return
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}}
}
}
// In DMs, users can only delete their own messages (no mod override).
isMod := !delIsDM && h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages)
if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message"))
return
isMod := !delIsDM && hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.ManageMessages)
if err := d.DB.DeleteMessage(msgID, userID, isMod); err != nil {
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}}
}
slog.Debug("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
_ = h.db.LogAudit(c.userID, "message_delete", "message", msgID,
slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
_ = d.DB.LogAudit(userID, "message_delete", "message", msgID,
fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod))
deletedMsg := buildChatDeleted(msgID, msg.ChannelID)
deletedPayload := buildChatDeleted(msgID, msg.ChannelID)
if delIsDM {
h.broadcastToDMParticipants(msg.ChannelID, deletedMsg)
} else {
h.BroadcastToChannel(msg.ChannelID, deletedMsg)
participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID)
if pErr != nil {
slog.Error("handleChatDeleteV2 GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
return Result{}
}
return Result{Events: []Event{MessageDeletedDMEvent{
channelID: msg.ChannelID,
participantIDs: participantIDs,
payload: deletedPayload,
}}}
}
return Result{Events: []Event{MessageDeletedChannelEvent{
channelID: msg.ChannelID,
payload: deletedPayload,
}}}
}
+13 -9
View File
@@ -2,17 +2,21 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"time"
)
// registerPingHandler registers the ping/pong handler.
func registerPingHandler(r *HandlerRegistry) {
r.Register(MsgTypePing, func(_ context.Context, h *Hub, c *Client, _ string, _ json.RawMessage) {
if !h.limiter.Allow(fmt.Sprintf("ping:%d", c.userID), 2, time.Second) {
return
}
c.sendMsg(buildJSON(map[string]any{"type": MsgTypePong}))
})
// handlePingV2 is the V2 handler for ping (heartbeat) messages.
// It rate-limits and returns a pong reply on success.
func handlePingV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(PingDeps)
if d.Limiter != nil && !d.Limiter.Allow(fmt.Sprintf("ping:%d", info.UserID), 2, time.Second) {
return Result{} // rate limited: silent drop
}
return Result{Reply: buildJSON(map[string]any{"type": MsgTypePong})}
}
// registerPingHandler registers the ping/pong handler (V2).
func registerPingHandler(r *HandlerRegistry, deps PingDeps) {
r.RegisterV2(MsgTypePing, handlePingV2, deps)
}
+137 -106
View File
@@ -2,134 +2,165 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/permissions"
)
// validPresenceStatuses is the set of accepted status values for presence_update.
var validPresenceStatuses = map[string]bool{
"online": true, "idle": true, "dnd": true, "offline": true,
}
// registerPresenceHandlers registers presence, typing, and channel focus handlers.
func registerPresenceHandlers(r *HandlerRegistry) {
r.Register(MsgTypeTypingStart, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleTyping(ctx, c, payload)
})
r.Register(MsgTypePresenceUpdate, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handlePresence(ctx, c, payload)
})
r.Register(MsgTypeChannelFocus, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleChannelFocus(ctx, c, payload)
})
// All three are V2 handlers.
func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) {
r.RegisterV2(MsgTypeTypingStart, handleTypingV2, deps)
r.RegisterV2(MsgTypePresenceUpdate, handlePresenceV2, deps)
r.RegisterV2(MsgTypeChannelFocus, handleChannelFocusV2, deps)
}
// handleTyping processes a typing_start message.
func (h *Hub) handleTyping(_ context.Context, c *Client, payload json.RawMessage) {
channelID, err := parseChannelID(payload)
if err != nil || channelID <= 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be positive integer"))
return
// handleTypingV2 is the V2 handler for typing_start messages.
// It validates the channel, checks permissions, and returns events to broadcast
// the typing indicator to channel members (excluding the sender).
func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(PresenceDeps)
typingCmd := cmd.(TypingStartCmd)
channelID := typingCmd.ChannelID()
userID := info.UserID
// Rate limit.
ratKey := fmt.Sprintf("typing:%d:%d", userID, channelID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, typingRateLimit, typingWindow) {
return Result{} // silently drop; no error for typing throttle
}
ratKey := fmt.Sprintf("typing:%d:%d", c.userID, channelID)
if !h.limiter.Allow(ratKey, typingRateLimit, typingWindow) {
return // silently drop; no error for typing throttle
// Channel lookup.
ch, err := d.DB.GetChannel(channelID)
if err != nil || ch == nil {
return Result{} // silently drop for unknown channels
}
// DM channels require participant check instead of role-based permissions.
typCh, typChErr := h.db.GetChannel(channelID)
if typChErr != nil || typCh == nil {
return // silently drop for unknown channels
}
if typCh.Type == "dm" {
ok, dmErr := h.db.IsDMParticipant(c.userID, channelID)
if dmErr != nil || !ok {
return // silently drop — not a DM participant
}
} else if !h.hasChannelPerm(c, channelID, permissions.ReadMessages) {
return // silently drop — no read permission on this channel
}
var username string
if c.user != nil {
username = c.user.Username
}
// Broadcast to channel, excluding sender.
if typCh.Type == "dm" {
h.broadcastToDMParticipantsExclude(channelID, c.userID, buildTypingMsg(channelID, c.userID, username))
} else {
h.broadcastExclude(channelID, c.userID, buildTypingMsg(channelID, c.userID, username))
}
}
// handlePresence processes a presence_update message.
func (h *Hub) handlePresence(_ context.Context, c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("presence:%d", c.userID)
if !h.limiter.Allow(ratKey, presenceRateLimit, presenceWindow) {
c.sendMsg(buildRateLimitError("too many presence updates", presenceWindow.Seconds()))
return
}
var p struct {
Status string `json:"status"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid presence_update payload"))
return
}
validStatuses := map[string]bool{"online": true, "idle": true, "dnd": true, "offline": true}
if !validStatuses[p.Status] {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "status must be online|idle|dnd|offline"))
return
}
if err := h.db.UpdateUserStatus(c.userID, p.Status); err != nil {
slog.Error("ws handlePresence UpdateUserStatus", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update status"))
return
}
h.BroadcastToAll(buildPresenceMsg(c.userID, p.Status))
}
// handleChannelFocus sets which channel the client is currently viewing,
// so channel-scoped broadcasts (chat messages, typing) reach them.
// Also updates read_states so unread counts decrease when the user views a channel.
func (h *Hub) handleChannelFocus(_ context.Context, c *Client, payload json.RawMessage) {
chID, err := parseChannelID(payload)
if err != nil || chID <= 0 {
slog.Debug("handleChannelFocus: invalid channel_id", "user_id", c.userID, "err", err)
return
}
// DM channels use participant-based auth instead of role-based permissions.
ch, chErr := h.db.GetChannel(chID)
if chErr != nil || ch == nil {
slog.Debug("handleChannelFocus: channel not found", "channel_id", chID)
return
}
// Permission check.
if ch.Type == "dm" {
ok, dmErr := h.db.IsDMParticipant(c.userID, chID)
ok, dmErr := d.DB.IsDMParticipant(userID, channelID)
if dmErr != nil || !ok {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not a participant in this DM"))
return
return Result{} // silently drop — not a DM participant
}
} else {
if !hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ReadMessages) {
return Result{} // silently drop — no read permission
}
} else if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") {
return
}
c.mu.Lock()
prevCh := c.channelID
c.channelID = chID
c.mu.Unlock()
payload := buildTypingMsg(channelID, userID, info.Username)
slog.Debug("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh)
if ch.Type == "dm" {
// For DM channels, get participant IDs and send to each excluding sender.
participantIDs, pErr := d.DB.GetDMParticipantIDs(channelID)
if pErr != nil {
return Result{} // silently drop on error
}
// Build one TypingDMEvent per other participant (UserTargetedEvent routing).
var events []Event
for _, pid := range participantIDs {
if pid == userID {
continue
}
events = append(events, TypingDMEvent{
targetUserID: pid,
payload: payload,
})
}
return Result{Events: events}
}
// Regular channel: ExcludeSenderEvent routing.
return Result{
Events: []Event{
TypingChannelEvent{
channelID: channelID,
excludeUserID: userID,
payload: payload,
},
},
}
}
// handlePresenceV2 is the V2 handler for presence_update messages.
// It validates the status, updates the DB, and broadcasts to all clients.
func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(PresenceDeps)
presenceCmd := cmd.(PresenceUpdateCmd)
userID := info.UserID
// Rate limit.
ratKey := fmt.Sprintf("presence:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, presenceRateLimit, presenceWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many presence updates"}}
}
// Validate status.
status := presenceCmd.Status()
if !validPresenceStatuses[status] {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "status must be online|idle|dnd|offline"}}
}
// Update DB.
if err := d.DB.UpdateUserStatus(userID, status); err != nil {
slog.Error("ws handlePresenceV2 UpdateUserStatus", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update status"}}
}
// Broadcast to all connected clients.
return Result{
Events: []Event{
PresenceEvent{payload: buildPresenceMsg(userID, status)},
},
}
}
// handleChannelFocusV2 is the V2 handler for channel_focus messages.
// It validates permissions, signals the client's focused channel via SetChannelID,
// and marks the channel as read.
func handleChannelFocusV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(PresenceDeps)
focusCmd := cmd.(ChannelFocusCmd)
chID := focusCmd.ChannelID()
userID := info.UserID
if chID <= 0 {
return Result{} // silently drop invalid channel_id
}
// Channel lookup.
ch, chErr := d.DB.GetChannel(chID)
if chErr != nil || ch == nil {
return Result{} // silently drop — channel not found
}
// Permission check.
if ch.Type == "dm" {
ok, dmErr := d.DB.IsDMParticipant(userID, chID)
if dmErr != nil || !ok {
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "not a participant in this DM"}}
}
} else {
if denied := requirePerm(d.DB, d.Permissions, userID, chID, permissions.ReadMessages, "READ_MESSAGES"); denied != nil {
return *denied
}
}
slog.Debug("channel_focus", "user_id", userID, "channel_id", chID)
// Mark channel as read by updating read_states to the latest message.
latestID, latestErr := h.db.GetLatestMessageID(chID)
latestID, latestErr := d.DB.GetLatestMessageID(chID)
if latestErr == nil && latestID > 0 {
if rsErr := h.db.UpdateReadState(c.userID, chID, latestID); rsErr != nil {
slog.Warn("handleChannelFocus UpdateReadState", "err", rsErr, "user_id", c.userID, "channel_id", chID)
if rsErr := d.DB.UpdateReadState(userID, chID, latestID); rsErr != nil {
slog.Warn("handleChannelFocusV2 UpdateReadState", "err", rsErr, "user_id", userID, "channel_id", chID)
}
}
return Result{SetChannelID: &chID}
}
+103 -92
View File
@@ -2,111 +2,122 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/permissions"
)
// registerReactionHandlers registers reaction_add and reaction_remove handlers.
func registerReactionHandlers(r *HandlerRegistry) {
r.Register(MsgTypeReactionAdd, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleReaction(ctx, c, true, payload)
})
r.Register(MsgTypeReactionRemove, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleReaction(ctx, c, false, payload)
})
// registerReactionHandlers registers reaction_add and reaction_remove V2 handlers.
func registerReactionHandlers(r *HandlerRegistry, deps ReactionDeps) {
r.RegisterV2(MsgTypeReactionAdd, reactionV2Handler(true), deps)
r.RegisterV2(MsgTypeReactionRemove, reactionV2Handler(false), deps)
}
// handleReaction processes reaction_add and reaction_remove messages.
func (h *Hub) handleReaction(_ context.Context, c *Client, add bool, payload json.RawMessage) {
ratKey := fmt.Sprintf("reaction:%d", c.userID)
if !h.limiter.Allow(ratKey, reactionRateLimit, reactionWindow) {
c.sendMsg(buildRateLimitError("too many reactions", reactionWindow.Seconds()))
return
}
// reactionV2Handler returns a V2 handler for reaction_add (add=true) or
// reaction_remove (add=false). Both share identical validation and routing.
func reactionV2Handler(add bool) HandlerV2 {
return func(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ReactionDeps)
userID := info.UserID
var p struct {
MessageID json.Number `json:"message_id"`
Emoji string `json:"emoji"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid reaction payload"))
return
}
msgID, err := p.MessageID.Int64()
if err != nil || msgID <= 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
return
}
if p.Emoji == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji cannot be empty"))
return
}
if len(p.Emoji) > 32 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji too long"))
return
}
// Reject control characters (U+0000-U+001F, U+007F) to prevent injection.
for _, r := range p.Emoji {
if r < 0x20 || r == 0x7F {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji contains invalid characters"))
return
var msgID int64
var emoji string
if add {
c := cmd.(ReactionAddCmd)
msgID = c.MessageID()
emoji = c.Emoji()
} else {
c := cmd.(ReactionRemoveCmd)
msgID = c.MessageID()
emoji = c.Emoji()
}
}
// Sanitize HTML to prevent stored XSS via emoji field.
if sanitized := sanitizer.Sanitize(p.Emoji); sanitized != p.Emoji {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji contains invalid characters"))
return
}
msg, err := h.db.GetMessage(msgID)
if err != nil || msg == nil {
// Normalize: return same error whether message doesn't exist or is in
// a channel the user can't see (prevents IDOR information leak).
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed"))
return
}
// BUG-126: Reject reactions on soft-deleted messages.
if msg.Deleted {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed"))
return
}
// Check channel type for DM-aware permission handling.
reactCh, chErr := h.db.GetChannel(msg.ChannelID)
reactIsDM := chErr == nil && reactCh != nil && reactCh.Type == "dm"
if reactIsDM {
ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID)
if dmErr != nil || !ok {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed"))
return
// Rate limit.
ratKey := fmt.Sprintf("reaction:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, reactionRateLimit, reactionWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many reactions"}}
}
} else if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") {
return
}
action := "add"
if add {
err = h.db.AddReaction(msgID, c.userID, p.Emoji)
} else {
action = "remove"
err = h.db.RemoveReaction(msgID, c.userID, p.Emoji)
}
if err != nil {
// Sanitize: never leak raw DB constraint errors to client.
slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", c.userID, "err", err)
c.sendMsg(buildErrorMsg(ErrCodeConflict, "reaction failed"))
return
}
// Validate fields.
if msgID <= 0 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}}
}
if emoji == "" {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji cannot be empty"}}
}
if len(emoji) > 32 {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji too long"}}
}
// Reject control characters (U+0000-U+001F, U+007F) to prevent injection.
for _, r := range emoji {
if r < 0x20 || r == 0x7F {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji contains invalid characters"}}
}
}
// Sanitize HTML to prevent stored XSS via emoji field.
if sanitized := sanitizer.Sanitize(emoji); sanitized != emoji {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji contains invalid characters"}}
}
reactionMsg := buildReactionUpdate(msgID, msg.ChannelID, c.userID, p.Emoji, action)
if reactIsDM {
h.broadcastToDMParticipants(msg.ChannelID, reactionMsg)
} else {
h.BroadcastToChannel(msg.ChannelID, reactionMsg)
// Look up message.
msg, err := d.DB.GetMessage(msgID)
if err != nil || msg == nil {
// Normalize: same error whether message doesn't exist or is in a
// channel the user can't see (prevents IDOR information leak).
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}}
}
// BUG-126: Reject reactions on soft-deleted messages.
if msg.Deleted {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}}
}
// Check channel type for DM-aware permission handling.
reactCh, chErr := d.DB.GetChannel(msg.ChannelID)
reactIsDM := chErr == nil && reactCh != nil && reactCh.Type == "dm"
if reactIsDM {
ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID)
if dmErr != nil || !ok {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}}
}
} else {
if denied := requirePerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS"); denied != nil {
return *denied
}
}
// Execute reaction.
action := "add"
if add {
err = d.DB.AddReaction(msgID, userID, emoji)
} else {
action = "remove"
err = d.DB.RemoveReaction(msgID, userID, emoji)
}
if err != nil {
// Sanitize: never leak raw DB constraint errors to client.
slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", userID, "err", err)
return Result{Error: ClientError{Code: ErrCodeConflict, Message: "reaction failed"}}
}
reactionPayload := buildReactionUpdate(msgID, msg.ChannelID, userID, emoji, action)
if reactIsDM {
participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID)
if pErr != nil {
slog.Error("reactionV2Handler GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
return Result{}
}
return Result{Events: []Event{ReactionDMEvent{
channelID: msg.ChannelID,
participantIDs: participantIDs,
payload: reactionPayload,
}}}
}
return Result{Events: []Event{ReactionChannelEvent{
channelID: msg.ChannelID,
payload: reactionPayload,
}}}
}
}
+7 -3
View File
@@ -1907,7 +1907,7 @@ func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) {
// TestChannelFocus_InvalidChannelID_NoResponse verifies that a channel_focus
// with channel_id=0 is silently ignored (no crash, no error message).
func TestChannelFocus_InvalidChannelID_NoResponse(t *testing.T) {
func TestChannelFocus_InvalidChannelID_ReturnsBadRequest(t *testing.T) {
hub, database := newHandlerHub(t)
user := seedOwnerUser(t, database, "focus-invalid1")
@@ -1923,17 +1923,21 @@ func TestChannelFocus_InvalidChannelID_NoResponse(t *testing.T) {
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
// No error or other message should be sent for invalid channel_id.
// Constructor rejects channel_id <= 0 with BAD_REQUEST.
msgs := drainChan(send)
found := false
for _, m := range msgs {
var env map[string]any
if err := json.Unmarshal(m, &env); err != nil {
continue
}
if env["type"] == "error" {
t.Errorf("expected silent ignore for channel_id=0, but got error: %s", m)
found = true
}
}
if !found {
t.Error("expected BAD_REQUEST error for channel_id=0, got nothing")
}
}
// ─── handleMessage ban check (T-044) ─────────────────────────────────────────
+15 -26
View File
@@ -5,36 +5,25 @@ import (
"encoding/json"
)
// registerVoiceHandlers registers all voice-related message handlers.
// The handler methods themselves live in voice_join.go, voice_leave.go,
// voice_controls.go, and voice_broadcast.go — this function only wires
// them into the registry.
func registerVoiceHandlers(r *HandlerRegistry) {
// registerVoiceHandlersV1 registers voice handlers that remain V1 (complex
// state management that hasn't been migrated yet).
func registerVoiceHandlersV1(r *HandlerRegistry) {
r.Register(MsgTypeVoiceJoin, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceJoin(ctx, c, payload)
})
r.Register(MsgTypeVoiceLeave, func(ctx context.Context, h *Hub, c *Client, _ string, _ json.RawMessage) {
h.handleVoiceLeave(ctx, c)
})
r.Register(MsgTypeVoiceTokenRefresh, func(ctx context.Context, h *Hub, c *Client, _ string, _ json.RawMessage) {
h.handleVoiceTokenRefresh(ctx, c)
})
r.Register(MsgTypeVoiceMute, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceMute(ctx, c, payload)
})
r.Register(MsgTypeVoiceDeafen, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceDeafen(ctx, c, payload)
})
r.Register(MsgTypeVoiceCamera, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceCamera(ctx, c, payload)
})
r.Register(MsgTypeVoiceScreenshare, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceScreenshare(ctx, c, payload)
})
r.Register(MsgTypeVoiceE2EEAnnounce, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceE2EEAnnounce(ctx, c, payload)
})
r.Register(MsgTypeVoiceE2EEOffer, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) {
h.handleVoiceE2EEOffer(ctx, c, payload)
})
}
// registerVoiceControlsV2 registers V2 handlers for voice control toggles
// and other migrated voice handlers.
func registerVoiceControlsV2(r *HandlerRegistry, deps VoiceDeps) {
r.RegisterV2(MsgTypeVoiceMute, handleVoiceMuteV2, deps)
r.RegisterV2(MsgTypeVoiceDeafen, handleVoiceDeafenV2, deps)
r.RegisterV2(MsgTypeVoiceCamera, handleVoiceCameraV2, deps)
r.RegisterV2(MsgTypeVoiceScreenshare, handleVoiceScreenshareV2, deps)
r.RegisterV2(MsgTypeVoiceE2EEAnnounce, handleVoiceE2EEAnnounceV2, deps)
r.RegisterV2(MsgTypeVoiceE2EEOffer, handleVoiceE2EEOfferV2, deps)
r.RegisterV2(MsgTypeVoiceTokenRefresh, handleVoiceTokenRefreshV2, deps)
}
+46 -5
View File
@@ -62,11 +62,7 @@ type Hub struct {
// It also initializes the settings cache from the database.
func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
reg := NewHandlerRegistry()
registerChatHandlers(reg)
registerPresenceHandlers(reg)
registerReactionHandlers(reg)
registerVoiceHandlers(reg)
registerPingHandler(reg)
registerVoiceHandlersV1(reg)
h := &Hub{
clients: make(map[int64]*Client),
@@ -83,6 +79,33 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
settingsMotd: "Welcome!",
voiceKeyHolders: make(map[int64]int64),
}
// V2 handler registrations (need Hub fields for deps).
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
registerChatHandlers(reg, ChatDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
})
registerPresenceHandlers(reg, PresenceDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
})
registerReactionHandlers(reg, ReactionDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
})
registerVoiceControlsV2(reg, VoiceDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
LiveKit: h.livekit,
TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit)
KeyHolder: h,
})
h.refreshSettingsLocked()
return h
}
@@ -128,6 +151,24 @@ func (h *Hub) SetLiveKit(lk *LiveKitClient) {
h.livekit = lk
}
// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit
// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed
// as a dep at registration time (before SetLiveKit is called).
func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) {
if h.livekit == nil {
return "", fmt.Errorf("voice not configured")
}
return h.livekit.GenerateToken(userID, username, channelID, voiceJoinToken, canPublish, canSubscribe, canVideo, canScreenShare)
}
// URL delegates to the LiveKit client. Returns empty string if not configured.
func (h *Hub) URL() string {
if h.livekit == nil {
return ""
}
return h.livekit.URL()
}
// LiveKitHealthCheck probes the LiveKit server for connectivity.
// It tries the SDK client first (ListRooms), and falls back to an HTTP probe
// if a managed process is configured. Returns false with a reason if LiveKit
-24
View File
@@ -131,24 +131,12 @@ type voiceTokenPayload struct {
// ── Voice E2EE (client-side ECDH key exchange) ─────────────────────────────
// voiceE2EEAnnounceIn is the client→server payload for voice_e2ee_announce.
type voiceE2EEAnnounceIn struct {
PublicKey string `json:"public_key"`
}
// voiceE2EEAnnounceBroadcast is the server→client relay with user_id added.
type voiceE2EEAnnounceBroadcast struct {
UserID int64 `json:"user_id"`
PublicKey string `json:"public_key"`
}
// voiceE2EEOfferIn is the client→server payload for voice_e2ee_offer.
type voiceE2EEOfferIn struct {
TargetUserID int64 `json:"target_user_id"`
EncryptedKey string `json:"encrypted_key"`
IV string `json:"iv"`
}
// voiceE2EEOfferRelay is the server→client relay with from_user_id.
type voiceE2EEOfferRelay struct {
FromUserID int64 `json:"from_user_id"`
@@ -219,18 +207,6 @@ func buildErrorMsg(code, message string) []byte {
})
}
// buildRateLimitError produces a RATE_LIMITED error with retry_after per PROTOCOL.md.
func buildRateLimitError(message string, retryAfterSeconds float64) []byte {
return buildJSON(map[string]any{
"type": MsgTypeError,
"payload": map[string]any{
"code": "RATE_LIMITED",
"message": message,
"retry_after": retryAfterSeconds,
},
})
}
// buildAuthError produces an auth_error envelope per PROTOCOL.md.
// The client treats this type as non-recoverable and stops reconnecting.
func buildAuthError(message string) []byte {
+77 -2
View File
@@ -3,6 +3,9 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"runtime"
)
// MessageHandler is the function signature for all WebSocket message handlers.
@@ -10,17 +13,25 @@ import (
// the sending client, the request ID from the envelope, and the raw JSON payload.
type MessageHandler func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage)
// handlerV2Entry pairs a V2 handler with its domain-specific dependency struct.
type handlerV2Entry struct {
handler HandlerV2
deps any // concrete deps struct for this handler's domain
}
// HandlerRegistry maps message type strings to their handler functions.
// It is not safe for concurrent use after initialization; all Register
// calls must happen before any Dispatch calls.
type HandlerRegistry struct {
handlers map[string]MessageHandler
handlers map[string]MessageHandler // V1 — unchanged
handlersV2 map[string]handlerV2Entry // V2 — new
}
// NewHandlerRegistry creates an empty handler registry.
func NewHandlerRegistry() *HandlerRegistry {
return &HandlerRegistry{
handlers: make(map[string]MessageHandler),
handlers: make(map[string]MessageHandler),
handlersV2: make(map[string]handlerV2Entry),
}
}
@@ -49,3 +60,67 @@ func (r *HandlerRegistry) RegisteredTypes() []string {
}
return types
}
// RegisterV2 registers a V2 handler for the given command type.
// PANICS if cmdType is already registered in V1 (shadowing guard) or V2 (duplicate guard).
// The shadowing guard prevents accidentally having both V1 and V2 handlers for the
// same type. When migrating a handler, remove V1 registration BEFORE adding V2.
func (r *HandlerRegistry) RegisterV2(cmdType string, handler HandlerV2, deps any) {
if _, exists := r.handlers[cmdType]; exists {
panic(fmt.Sprintf("RegisterV2: cmdType %q already registered in V1 (remove V1 first)", cmdType))
}
if _, exists := r.handlersV2[cmdType]; exists {
panic(fmt.Sprintf("RegisterV2: cmdType %q already registered in V2", cmdType))
}
r.handlersV2[cmdType] = handlerV2Entry{handler: handler, deps: deps}
}
// DispatchV2 looks up a V2 handler and calls it.
// Returns (result, true) if found, (Result{}, false) if not.
// Recovers from panics (e.g. bad type assertions on cmd or deps) to prevent
// a single malformed message from crashing the entire server.
func (r *HandlerRegistry) DispatchV2(ctx context.Context, cmd Command, info ClientInfo) (result Result, ok bool) {
entry, found := r.handlersV2[cmd.Type()]
if !found {
return Result{}, false
}
defer func() {
if rec := recover(); rec != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
// TODO: stack trace may contain sensitive function arguments
// (e.g. encrypted keys). Consider scrubbing or limiting frames.
slog.Error("DispatchV2 panic recovered",
"type", cmd.Type(),
"user_id", info.UserID,
"panic", rec,
"stack", string(buf[:n]),
)
result = Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}}
ok = true
}
}()
return entry.handler(ctx, cmd, info, entry.deps), true
}
// RegisteredV2Types returns all V2-registered message types (unordered).
// Intended for testing and diagnostics.
func (r *HandlerRegistry) RegisteredV2Types() []string {
types := make([]string, 0, len(r.handlersV2))
for t := range r.handlersV2 {
types = append(types, t)
}
return types
}
// hasV2 reports whether a V2 handler is registered for msgType.
func (r *HandlerRegistry) hasV2(msgType string) bool {
_, ok := r.handlersV2[msgType]
return ok
}
// IsRegisteredV1 checks if a type is registered in the V1 map.
func (r *HandlerRegistry) IsRegisteredV1(msgType string) bool {
_, ok := r.handlers[msgType]
return ok
}
+240 -24
View File
@@ -3,7 +3,9 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
)
@@ -36,47 +38,261 @@ func TestHandlerRegistry_DispatchUnknownType(t *testing.T) {
}
}
func TestRegisterV2AndDispatchV2(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
_ = deps.(PingDeps) // verify deps wiring
return Result{Reply: []byte("pong")}
}
r.RegisterV2("ping", handler, PingDeps{})
cmd := &PingCmd{}
info := ClientInfo{UserID: 1, Username: "test", ReqID: "r1"}
result, ok := r.DispatchV2(context.Background(), cmd, info)
if !ok {
t.Fatal("DispatchV2 returned false for registered type")
}
if string(result.Reply) != "pong" {
t.Errorf("expected reply %q, got %q", "pong", string(result.Reply))
}
}
func TestDispatchV2_UnknownType_ReturnsFalse(t *testing.T) {
r := NewHandlerRegistry()
cmd := &PingCmd{}
_, ok := r.DispatchV2(context.Background(), cmd, ClientInfo{})
if ok {
t.Fatal("DispatchV2 returned true for unregistered type")
}
}
func TestDispatchV2_PanicIsRecovered(t *testing.T) {
r := NewHandlerRegistry()
r.RegisterV2("ping", func(_ context.Context, _ Command, _ ClientInfo, _ any) Result {
panic("simulated internal panic")
}, PingDeps{})
result, ok := r.DispatchV2(context.Background(), &PingCmd{userID: 1}, ClientInfo{})
if !ok {
t.Fatal("DispatchV2 must return ok=true even after a panic (handler was found)")
}
ce, isCE := result.Error.(ClientError)
if !isCE {
t.Fatalf("expected ClientError after panic recovery, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Errorf("expected ErrCodeInternal, got %q", ce.Code)
}
}
func TestRegisterV2_ShadowingGuard_Panics(t *testing.T) {
r := NewHandlerRegistry()
r.Register("ping", func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {})
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic from shadowing guard, got none")
}
}()
r.RegisterV2("ping", func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{}
}, PingDeps{})
}
func TestRegisterV2_DuplicateGuard_Panics(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{}
}
r.RegisterV2("ping", handler, PingDeps{})
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic from duplicate guard, got none")
}
}()
r.RegisterV2("ping", handler, PingDeps{})
}
func TestRegisteredV2Types(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{}
}
r.RegisterV2("ping", handler, PingDeps{})
r.RegisterV2("typing_start", handler, PresenceDeps{})
types := r.RegisteredV2Types()
sort.Strings(types)
if len(types) != 2 || types[0] != "ping" || types[1] != "typing_start" {
t.Errorf("expected [ping typing_start], got %v", types)
}
}
func TestV1StillWorks_WhenV2HasEntries(t *testing.T) {
r := NewHandlerRegistry()
v1Called := false
r.Register("chat_send", func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
v1Called = true
})
v2Handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{Reply: []byte("v2")}
}
r.RegisterV2("ping", v2Handler, PingDeps{})
// V1 dispatch still works
ok := r.Dispatch(context.Background(), "chat_send", nil, nil, "", nil)
if !ok || !v1Called {
t.Fatal("V1 dispatch broken when V2 has entries")
}
// V2 dispatch for its own type works
cmd := &PingCmd{}
result, handled := r.DispatchV2(context.Background(), cmd, ClientInfo{})
if !handled || string(result.Reply) != "v2" {
t.Fatal("V2 dispatch broken")
}
// V2 dispatch for V1-only type returns false
chatCmd := &ChatSendCmd{}
_, handled = r.DispatchV2(context.Background(), chatCmd, ClientInfo{})
if handled {
t.Fatal("V2 dispatch returned true for V1-only type")
}
}
func TestIsRegisteredV1(t *testing.T) {
r := NewHandlerRegistry()
r.Register("chat_send", func(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {})
if !r.IsRegisteredV1("chat_send") {
t.Fatal("expected true for registered V1 type")
}
if r.IsRegisteredV1("nonexistent") {
t.Fatal("expected false for unregistered type")
}
}
func TestHandlerRegistry_AllExpectedTypesRegistered(t *testing.T) {
r := NewHandlerRegistry()
registerChatHandlers(r)
registerPresenceHandlers(r)
registerReactionHandlers(r)
registerVoiceHandlers(r)
registerPingHandler(r)
registerVoiceHandlersV1(r)
registerPingHandler(r, PingDeps{})
registerChatHandlers(r, ChatDeps{})
registerPresenceHandlers(r, PresenceDeps{})
registerReactionHandlers(r, ReactionDeps{})
registerVoiceControlsV2(r, VoiceDeps{})
expected := []string{
"chat_send",
"chat_edit",
"chat_delete",
"reaction_add",
"reaction_remove",
// V1-only types (permanent — complex state/mutex requirements).
expectedV1 := []string{
"voice_join",
"voice_leave",
}
// V2-migrated types.
expectedV2 := []string{
"ping",
"typing_start",
"presence_update",
"channel_focus",
"voice_join",
"voice_leave",
"voice_token_refresh",
"reaction_add",
"reaction_remove",
"chat_send",
"chat_edit",
"chat_delete",
"voice_mute",
"voice_deafen",
"voice_camera",
"voice_screenshare",
"voice_e2ee_announce",
"voice_e2ee_offer",
"ping",
"voice_token_refresh",
}
registered := r.RegisteredTypes()
sort.Strings(registered)
sort.Strings(expected)
registeredV1 := r.RegisteredTypes()
sort.Strings(registeredV1)
sort.Strings(expectedV1)
if len(registered) != len(expected) {
t.Fatalf("expected %d registered types, got %d\nexpected: %v\ngot: %v",
len(expected), len(registered), expected, registered)
if len(registeredV1) != len(expectedV1) {
t.Fatalf("V1: expected %d registered types, got %d\nexpected: %v\ngot: %v",
len(expectedV1), len(registeredV1), expectedV1, registeredV1)
}
for i, typ := range expectedV1 {
if registeredV1[i] != typ {
t.Errorf("V1 mismatch at index %d: expected %q, got %q", i, typ, registeredV1[i])
}
}
for i, typ := range expected {
if registered[i] != typ {
t.Errorf("mismatch at index %d: expected %q, got %q", i, typ, registered[i])
registeredV2 := r.RegisteredV2Types()
sort.Strings(registeredV2)
sort.Strings(expectedV2)
if len(registeredV2) != len(expectedV2) {
t.Fatalf("V2: expected %d registered types, got %d\nexpected: %v\ngot: %v",
len(expectedV2), len(registeredV2), expectedV2, registeredV2)
}
for i, typ := range expectedV2 {
if registeredV2[i] != typ {
t.Errorf("V2 mismatch at index %d: expected %q, got %q", i, typ, registeredV2[i])
}
}
}
// TestAllV2Types_SmokeDispatch verifies that dispatching a minimal command
// for every V2-registered type does not panic (validates deps wiring).
func TestAllV2Types_SmokeDispatch(t *testing.T) {
r := NewHandlerRegistry()
registerPingHandler(r, PingDeps{})
registerChatHandlers(r, ChatDeps{})
registerPresenceHandlers(r, PresenceDeps{})
registerReactionHandlers(r, ReactionDeps{})
registerVoiceControlsV2(r, VoiceDeps{})
// Minimal command for each V2 type — just needs Type() and UserID().
cmds := map[string]Command{
MsgTypePing: PingCmd{userID: 1},
MsgTypeChatSend: ChatSendCmd{userID: 1, channelID: 1},
MsgTypeChatEdit: ChatEditCmd{userID: 1, messageID: 1},
MsgTypeChatDelete: ChatDeleteCmd{userID: 1, messageID: 1},
MsgTypeTypingStart: TypingStartCmd{userID: 1, channelID: 1},
MsgTypePresenceUpdate: PresenceUpdateCmd{userID: 1, status: "online"},
MsgTypeChannelFocus: ChannelFocusCmd{userID: 1, channelID: 1},
MsgTypeReactionAdd: ReactionAddCmd{userID: 1, messageID: 1, emoji: "👍"},
MsgTypeReactionRemove: ReactionRemoveCmd{userID: 1, messageID: 1, emoji: "👍"},
MsgTypeVoiceMute: VoiceMuteCmd{userID: 1},
MsgTypeVoiceDeafen: VoiceDeafenCmd{userID: 1},
MsgTypeVoiceCamera: VoiceCameraCmd{userID: 1},
MsgTypeVoiceScreenshare: VoiceScreenshareCmd{userID: 1},
MsgTypeVoiceE2EEAnnounce: VoiceE2EEAnnounceCmd{userID: 1},
MsgTypeVoiceE2EEOffer: VoiceE2EEOfferCmd{userID: 1},
MsgTypeVoiceTokenRefresh: VoiceTokenRefreshCmd{userID: 1},
}
for _, typ := range r.RegisteredV2Types() {
cmd, exists := cmds[typ]
if !exists {
t.Errorf("no smoke command defined for V2 type %q", typ)
continue
}
// We only care that the deps type assertion succeeds (no "interface
// conversion" panic). Nil-pointer panics from zero-value DB/Limiter
// fields are expected and harmless for this smoke test.
func() {
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprintf("%v", rec)
if strings.Contains(msg, "interface conversion") {
t.Errorf("V2 type %q: deps type assertion failed: %v", typ, rec)
}
// nil-pointer panics are expected with zero-value deps
}
}()
// Bypass DispatchV2's own recover so we can inspect the panic value.
entry := r.handlersV2[typ]
entry.handler(context.Background(), cmd, ClientInfo{UserID: 1}, entry.deps)
}()
}
}
+96 -125
View File
@@ -2,184 +2,155 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/permissions"
)
// handleVoiceMute processes a voice_mute message.
// 1. Parses muted bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceMute(_ context.Context, c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_mute:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceMuteRateLimit, voiceMuteWindow) {
c.sendMsg(buildRateLimitError("too many mute toggles", voiceMuteWindow.Seconds()))
return
// handleVoiceMuteV2 processes a voice_mute command.
func handleVoiceMuteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
muteCmd := cmd.(VoiceMuteCmd)
userID := info.UserID
ratKey := fmt.Sprintf("voice_mute:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceMuteRateLimit, voiceMuteWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many mute toggles"}}
}
if c.getVoiceChID() == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
if info.VoiceChannelID == 0 {
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
var p struct {
Muted bool `json:"muted"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_mute payload"))
return
if err := d.DB.UpdateVoiceMute(userID, muteCmd.Muted()); err != nil {
slog.Error("ws handleVoiceMuteV2 UpdateVoiceMute", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update mute state"}}
}
slog.Debug("voice mute changed", "user_id", userID, "muted", muteCmd.Muted(), "channel_id", info.VoiceChannelID)
if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil {
slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update mute state"))
return
}
slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted, "channel_id", c.getVoiceChID())
h.broadcastVoiceStateUpdate(c)
return voiceStateBroadcast(d, userID)
}
// handleVoiceDeafen processes a voice_deafen message.
// 1. Parses deafened bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceDeafen(_ context.Context, c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_deafen:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceDeafenRateLimit, voiceDeafenWindow) {
c.sendMsg(buildRateLimitError("too many deafen toggles", voiceDeafenWindow.Seconds()))
return
// handleVoiceDeafenV2 processes a voice_deafen command.
func handleVoiceDeafenV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
deafenCmd := cmd.(VoiceDeafenCmd)
userID := info.UserID
ratKey := fmt.Sprintf("voice_deafen:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceDeafenRateLimit, voiceDeafenWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many deafen toggles"}}
}
if c.getVoiceChID() == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
if info.VoiceChannelID == 0 {
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
var p struct {
Deafened bool `json:"deafened"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_deafen payload"))
return
if err := d.DB.UpdateVoiceDeafen(userID, deafenCmd.Deafened()); err != nil {
slog.Error("ws handleVoiceDeafenV2 UpdateVoiceDeafen", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update deafen state"}}
}
slog.Debug("voice deafen changed", "user_id", userID, "deafened", deafenCmd.Deafened(), "channel_id", info.VoiceChannelID)
if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil {
slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update deafen state"))
return
}
slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened, "channel_id", c.getVoiceChID())
h.broadcastVoiceStateUpdate(c)
return voiceStateBroadcast(d, userID)
}
// handleVoiceCamera processes a voice_camera message.
// 1. Rate limits at 2/sec per user.
// 2. Checks USE_VIDEO permission.
// 3. Parses enabled bool.
// 4. Enforces MaxVideo limit via DB count (race-free).
// 5. Updates DB.
// 6. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceCamera(_ context.Context, c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_camera:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) {
c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds()))
return
// handleVoiceCameraV2 processes a voice_camera command.
func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
cameraCmd := cmd.(VoiceCameraCmd)
userID := info.UserID
voiceChID := info.VoiceChannelID
ratKey := fmt.Sprintf("voice_camera:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many camera toggles"}}
}
voiceChID := c.getVoiceChID()
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") {
return
// Permission check.
if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil {
return *r
}
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_camera payload"))
return
}
enabled := cameraCmd.Enabled()
// Enforce MaxVideo limit when enabling camera using an atomic check-and-update.
if p.Enabled {
ch, chErr := h.db.GetChannel(voiceChID)
if enabled {
ch, chErr := d.DB.GetChannel(voiceChID)
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 {
ok, limitErr := h.db.EnableCameraIfUnderLimit(c.userID, voiceChID, ch.VoiceMaxVideo)
ok, limitErr := d.DB.EnableCameraIfUnderLimit(userID, voiceChID, ch.VoiceMaxVideo)
if limitErr != nil {
slog.Error("handleVoiceCamera EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check video limit"))
return
slog.Error("handleVoiceCameraV2 EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check video limit"}}
}
if !ok {
c.sendMsg(buildErrorMsg(ErrCodeVideoLimit,
fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo)))
return
return Result{Error: ClientError{
Code: ErrCodeVideoLimit,
Message: fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo),
}}
}
} else {
if err := h.db.UpdateVoiceCamera(c.userID, true); err != nil {
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
return
if err := d.DB.UpdateVoiceCamera(userID, true); err != nil {
slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}}
}
}
} else {
if err := h.db.UpdateVoiceCamera(c.userID, false); err != nil {
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
return
if err := d.DB.UpdateVoiceCamera(userID, false); err != nil {
slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}}
}
}
slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled, "channel_id", voiceChID)
slog.Debug("voice camera changed", "user_id", userID, "enabled", enabled, "channel_id", voiceChID)
h.broadcastVoiceStateUpdate(c)
return voiceStateBroadcast(d, userID)
}
// handleVoiceScreenshare processes a voice_screenshare message.
// 1. Rate limits at 2/sec per user.
// 2. Checks SHARE_SCREEN permission.
// 3. Parses enabled bool.
// 4. Updates DB.
// 5. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceScreenshare(_ context.Context, c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) {
c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds()))
return
// handleVoiceScreenshareV2 processes a voice_screenshare command.
func handleVoiceScreenshareV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
ssCmd := cmd.(VoiceScreenshareCmd)
userID := info.UserID
voiceChID := info.VoiceChannelID
ratKey := fmt.Sprintf("voice_screenshare:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many screenshare toggles"}}
}
voiceChID := c.getVoiceChID()
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") {
return
// Permission check.
if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil {
return *r
}
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_screenshare payload"))
return
if err := d.DB.UpdateVoiceScreenshare(userID, ssCmd.Enabled()); err != nil {
slog.Error("ws handleVoiceScreenshareV2 UpdateVoiceScreenshare", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update screenshare state"}}
}
slog.Debug("voice screenshare changed", "user_id", userID, "enabled", ssCmd.Enabled(), "channel_id", voiceChID)
if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil {
slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update screenshare state"))
return
return voiceStateBroadcast(d, userID)
}
// voiceStateBroadcast reads the current voice state from DB and returns a
// BroadcastAll event. Shared by all voice control V2 handlers.
func voiceStateBroadcast(d VoiceDeps, userID int64) Result {
state, err := d.DB.GetVoiceState(userID)
if err != nil {
slog.Error("ws voiceStateBroadcast GetVoiceState", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to broadcast voice state update"}}
}
if state == nil {
return Result{} // not in voice — nothing to broadcast
}
slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled, "channel_id", voiceChID)
h.broadcastVoiceStateUpdate(c)
return Result{Events: []Event{VoiceStateEvent{payload: buildVoiceState(*state)}}}
}
+90 -84
View File
@@ -3,8 +3,6 @@ package ws
import (
"context"
"encoding/base64"
"encoding/json"
"log/slog"
)
// decodeBase64Loose accepts both padded (StdEncoding) and unpadded (RawStdEncoding)
@@ -50,8 +48,13 @@ func (h *Hub) updateKeyHolder(channelID int64) {
}
}
// isVoiceKeyHolder reports whether userID is the current key holder for channelID.
func (h *Hub) isVoiceKeyHolder(channelID, userID int64) bool {
// IsVoiceKeyHolder reports whether userID is the current key holder for channelID.
// Satisfies the KeyHolderChecker interface.
//
// NOTE: When called from a V2 handler (via deps), there is a TOCTOU window
// between this check and the subsequent event delivery in EmitEvents. See the
// comment on handleVoiceE2EEOfferV2 for details.
func (h *Hub) IsVoiceKeyHolder(channelID, userID int64) bool {
h.keyHolderMu.RLock()
kh, ok := h.voiceKeyHolders[channelID]
h.keyHolderMu.RUnlock()
@@ -73,110 +76,113 @@ func (h *Hub) computeIsKeyHolder(channelID, userID int64) bool {
return true
}
// handleVoiceE2EEAnnounce processes a client's ECDH public key announcement.
// The server stores the key on the Client struct and relays it to all other
// participants in the same voice channel. The server never sees or generates
// the room encryption key — only opaque public keys pass through.
func (h *Hub) handleVoiceE2EEAnnounce(_ context.Context, c *Client, payload json.RawMessage) {
voiceChID := c.getVoiceChID()
// handleVoiceE2EEAnnounceV2 is the V2 (pure) handler for voice_e2ee_announce.
// It validates the public key and returns a SetE2EEPubKey mutation plus a
// VoiceE2EEAnnounceEvent for relay to other voice channel participants.
func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
_ = deps.(VoiceDeps)
announceCmd := cmd.(VoiceE2EEAnnounceCmd)
userID := info.UserID
voiceChID := info.VoiceChannelID
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not in a voice channel"))
return
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
var p voiceE2EEAnnounceIn
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "invalid voice_e2ee_announce payload"))
return
pubKey := announceCmd.PublicKey()
if pubKey == "" {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key is required"}}
}
if p.PublicKey == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key is required"))
return
if len(pubKey) > 128 {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key too large"}}
}
// P-256 uncompressed public key = 65 bytes → 88 base64 chars.
// Allow up to 128 chars for padding tolerance.
if len(p.PublicKey) > 128 {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key too large"))
return
}
if _, err := decodeBase64Loose(p.PublicKey); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key is not valid base64"))
return
if _, err := decodeBase64Loose(pubKey); err != nil {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key is not valid base64"}}
}
// Store the public key on the client for later retrieval by new joiners.
c.setE2EEPubKey(p.PublicKey)
// Relay to all other clients in the same voice channel.
msg := buildVoiceE2EEAnnounce(c.userID, p.PublicKey)
h.sendToVoiceChannelExcept(voiceChID, c.userID, msg)
slog.Debug("voice e2ee: announce relayed", "user_id", c.userID, "channel_id", voiceChID)
msg := buildVoiceE2EEAnnounce(userID, pubKey)
return Result{
SetE2EEPubKey: &pubKey,
Events: []Event{VoiceE2EEAnnounceEvent{
voiceChannelID: voiceChID,
excludeUserID: userID,
payload: msg,
}},
}
}
// handleVoiceE2EEOffer relays an encrypted room key from one participant to
// another. The payload is opaque to the server — it contains an AES-GCM
// encrypted room key that only the target can decrypt via ECDH.
func (h *Hub) handleVoiceE2EEOffer(_ context.Context, c *Client, payload json.RawMessage) {
voiceChID := c.getVoiceChID()
// handleVoiceE2EEOfferV2 is the V2 (pure) handler for voice_e2ee_offer.
// It validates the payload and key holder status, then returns a
// VoiceE2EEOfferGuardedEvent for atomic check-and-send delivery.
//
// KNOWN RACE: There is a window between the IsVoiceKeyHolder check below
// and the sendToUserIfInVoiceChannel delivery in EmitEvents where the key
// holder map can change (e.g. the real key holder leaves voice). This is
// accepted because VoiceChannelGuardedEvent uses atomic check-and-send
// under h.mu.RLock, guaranteeing the target is still in the voice channel
// at delivery time. The worst case is a stale "not key holder" rejection
// that the client retries.
// TODO: consider re-checking key-holder status inside sendToUserIfInVoiceChannel
// under the same h.mu.RLock to close the TOCTOU window completely.
func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
offerCmd := cmd.(VoiceE2EEOfferCmd)
voiceChID := info.VoiceChannelID
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not in a voice channel"))
return
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
}
var p voiceE2EEOfferIn
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "invalid voice_e2ee_offer payload"))
return
targetUserID := offerCmd.TargetUserID()
encKey := offerCmd.EncryptedKey()
iv := offerCmd.IV()
if targetUserID <= 0 || encKey == "" || iv == "" {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "target_user_id, encrypted_key, and iv are required"}}
}
if p.TargetUserID <= 0 || p.EncryptedKey == "" || p.IV == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "target_user_id, encrypted_key, and iv are required"))
return
if len(encKey) > 1024 || len(iv) > 128 {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "encrypted_key or iv too large"}}
}
// Size limits: AES-256-GCM encrypted 32-byte key ≈ 64 base64 chars + 16-byte
// auth tag. 1024 chars is generous. IV is 12 bytes = 16 base64 chars.
if len(p.EncryptedKey) > 1024 || len(p.IV) > 128 {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "encrypted_key or iv too large"))
return
if _, err := decodeBase64Loose(encKey); err != nil {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "encrypted_key is not valid base64"}}
}
if _, err := decodeBase64Loose(p.EncryptedKey); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "encrypted_key is not valid base64"))
return
}
if _, err := decodeBase64Loose(p.IV); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "iv is not valid base64"))
return
if _, err := decodeBase64Loose(iv); err != nil {
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "iv is not valid base64"}}
}
// I-1: Only the designated key holder may distribute the room key. This
// prevents any other participant from performing a key substitution attack.
if !h.isVoiceKeyHolder(voiceChID, c.userID) {
c.sendMsg(buildErrorMsg(ErrCodeNotKeyHolder, "only the key holder may send key offers"))
return
if d.KeyHolder == nil {
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "key holder checker not configured"}}
}
if !d.KeyHolder.IsVoiceKeyHolder(voiceChID, info.UserID) {
return Result{Error: ClientError{Code: ErrCodeNotKeyHolder, Message: "only the key holder may send key offers"}}
}
// Verify the target is in the same voice channel, then relay — all under
// one h.mu.RLock hold so the check and send are atomic. A concurrent
// voice_leave cannot remove the target from h.clients between the lookup
// and the channel comparison, nor between the comparison and the send.
msg := buildVoiceE2EEOffer(c.userID, p.EncryptedKey, p.IV)
msg := buildVoiceE2EEOffer(info.UserID, encKey, iv)
return Result{
Events: []Event{VoiceE2EEOfferGuardedEvent{
voiceChannelID: voiceChID,
targetUserID: targetUserID,
payload: msg,
}},
}
}
// sendToUserIfInVoiceChannel atomically verifies that targetUserID is in the
// given voice channel and sends the message — all under a single h.mu.RLock.
// This prevents TOCTOU races where the target leaves voice between the check
// and the send. Used by VoiceChannelGuardedEvent (voice_e2ee_offer).
func (h *Hub) sendToUserIfInVoiceChannel(voiceChannelID, targetUserID int64, msg []byte) {
h.mu.RLock()
target, ok := h.clients[p.TargetUserID]
defer h.mu.RUnlock()
target, ok := h.clients[targetUserID]
if !ok {
h.mu.RUnlock()
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "target user not connected"))
return
return // target not connected — silently drop
}
if target.getVoiceChID() != voiceChID {
h.mu.RUnlock()
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "target user not in your voice channel"))
return
if target.getVoiceChID() != voiceChannelID {
return // target not in expected voice channel — silently drop
}
target.sendMsg(msg)
h.mu.RUnlock()
slog.Debug("voice e2ee: offer relayed",
"from_user_id", c.userID, "to_user_id", p.TargetUserID, "channel_id", voiceChID)
}
// sendToVoiceChannelExcept sends a message to all clients in the given voice
+46 -45
View File
@@ -48,6 +48,15 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
return
}
// Ensure authenticated user is present before any state changes.
// This guard covers all downstream paths (LiveKit configured or not)
// that dereference c.user (e.g. c.user.Username in the success log).
if c.user == nil {
slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
return
}
// Hard-fail when LiveKit is not configured — without an SFU the client
// cannot connect to voice, so persisting state would create a ghost.
if h.livekit == nil {
@@ -134,12 +143,6 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
// NOTE: setVoiceState is deferred until after token send succeeds, so
// rollback does not broadcast a spurious voice_leave for an unannounced join.
if h.livekit != nil {
if c.user == nil {
slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID)
h.rollbackVoiceJoin(c, channelID, false)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
return
}
// Derive publish permissions from role — prevents SFU-level bypass
// when client connects directly via direct_url (BUG-128).
canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice)
@@ -221,59 +224,57 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
)
}
// handleVoiceTokenRefresh generates a fresh LiveKit token for a client
// that is already in a voice channel. This lets clients request a new token
// (e.g. before a manual reconnect) without leaving and rejoining voice.
func (h *Hub) handleVoiceTokenRefresh(_ context.Context, c *Client) {
ratKey := fmt.Sprintf("voice_token_refresh:%d", c.userID)
if !h.limiter.Allow(ratKey, 1, 60*time.Second) {
c.sendMsg(buildRateLimitError("token refresh rate limited", 60))
return
// handleVoiceTokenRefreshV2 is the V2 (pure) handler for voice_token_refresh.
// It generates a fresh LiveKit token for a client already in a voice channel.
func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(VoiceDeps)
userID := info.UserID
channelID := info.VoiceChannelID
ratKey := fmt.Sprintf("voice_token_refresh:%d", userID)
if d.Limiter != nil && !d.Limiter.Allow(ratKey, 1, 60*time.Second) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "token refresh rate limited"}}
}
channelID := c.getVoiceChID()
if channelID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "not in voice"))
return
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "not in voice"}}
}
if h.livekit == nil {
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice not configured"))
return
if d.TokenGen == nil {
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice not configured"}}
}
if c.user == nil {
slog.Error("handleVoiceTokenRefresh: nil user on client", "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
return
}
canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice)
canPublish := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice)
canSubscribe := true
canVideo := h.hasChannelPerm(c, channelID, permissions.UseVideo)
canScreenShare := h.hasChannelPerm(c, channelID, permissions.ShareScreen)
joinToken := c.getVoiceJoinToken()
canVideo := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.UseVideo)
canScreenShare := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ShareScreen)
joinToken := info.VoiceJoinToken
var result Result
if joinToken == "" {
state, stateErr := h.db.GetVoiceState(c.userID)
state, stateErr := d.DB.GetVoiceState(userID)
if stateErr != nil || state == nil {
slog.Error("ws handleVoiceTokenRefresh GetVoiceState", "err", stateErr, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to refresh voice token"))
return
slog.Error("ws handleVoiceTokenRefreshV2 GetVoiceState", "err", stateErr, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to refresh voice token"}}
}
joinToken = state.JoinedAt
c.setVoiceState(channelID, joinToken)
}
token, err := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, joinToken, canPublish, canSubscribe, canVideo, canScreenShare)
if err != nil {
slog.Error("ws handleVoiceTokenRefresh GenerateToken", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token"))
return
result.SetVoiceJoinToken = &joinToken
}
// E2EE keys are exchanged client-side via ECDH; token refresh only
// provides a new LiveKit access token.
c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL(), h.isVoiceKeyHolder(channelID, c.userID)))
slog.Info("voice token refreshed", "user_id", c.userID, "channel_id", channelID)
token, err := d.TokenGen.GenerateToken(userID, info.Username, channelID, joinToken, canPublish, canSubscribe, canVideo, canScreenShare)
if err != nil {
slog.Error("ws handleVoiceTokenRefreshV2 GenerateToken", "err", err, "user_id", userID)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to generate voice token"}}
}
isKeyHolder := false
if d.KeyHolder != nil {
isKeyHolder = d.KeyHolder.IsVoiceKeyHolder(channelID, userID)
}
result.Reply = buildVoiceToken(channelID, token, "/livekit", d.TokenGen.URL(), isKeyHolder)
slog.Info("voice token refreshed (v2)", "user_id", userID, "channel_id", channelID)
return result
}
// rollbackVoiceJoin undoes a partially-completed voice join: clears the