mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user