fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -21,7 +21,9 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
|
||||
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
||||
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
||||
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
||||
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
|
||||
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
|
||||
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
|
||||
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
39
server/src/services/session-auth.service.spec.ts
Normal file
39
server/src/services/session-auth.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getSessionTokenTtlMs } from './session-auth.service';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('session-auth.service', () => {
|
||||
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTtl === undefined) {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
} else {
|
||||
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults session tokens to a very long lifetime', () => {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
|
||||
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = '3600000';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { SessionTokenEntity } from '../entities/SessionTokenEntity';
|
||||
import { getUserById } from '../cqrs';
|
||||
import type { AuthUserPayload } from '../cqrs/types';
|
||||
|
||||
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface IssuedSessionToken {
|
||||
token: string;
|
||||
|
||||
102
server/src/websocket/broadcast.spec.ts
Normal file
102
server/src/websocket/broadcast.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast';
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws: createMockWs(),
|
||||
authenticated: true,
|
||||
serverIds: new Set(['server-1']),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('broadcastToServer', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('delivers chat_message to every connection in the server except the sender connection', () => {
|
||||
createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
|
||||
excludeConnectionId: 'conn-a1'
|
||||
});
|
||||
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||
const connA1 = createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
|
||||
excludeIdentityOderId: 'user-1'
|
||||
});
|
||||
|
||||
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVoiceActiveConnection', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('returns the connection marked voiceActive for the user', () => {
|
||||
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
|
||||
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBe(active);
|
||||
});
|
||||
|
||||
it('returns undefined when no voiceActive connection exists', () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
|
||||
const fallback = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findUserByOderId('user-1')).toBe(fallback);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,35 @@ interface WsMessage {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
export interface BroadcastOptions {
|
||||
/** Skip only the sending WebSocket connection. */
|
||||
excludeConnectionId?: string;
|
||||
/** Skip every open connection for this identity (presence events). */
|
||||
excludeIdentityOderId?: string;
|
||||
}
|
||||
|
||||
// Deduplicate by oderId so users with multiple connections (e.g. from
|
||||
// different signal URLs routing to the same server) receive the
|
||||
// broadcast only once.
|
||||
const sentToOderIds = new Set<string>();
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
|
||||
console.log(
|
||||
`Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
|
||||
`identity ${options?.excludeIdentityOderId ?? 'none'}:`,
|
||||
message.type
|
||||
);
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
|
||||
sentToOderIds.add(user.oderId);
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
!user.serverIds.has(serverId)
|
||||
|| connectionId === options?.excludeConnectionId
|
||||
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyOtherConnectionsForOderId(
|
||||
oderId: string,
|
||||
message: WsMessage,
|
||||
excludeConnectionId?: string
|
||||
): void {
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
connectionId === excludeConnectionId
|
||||
|| user.oderId !== oderId
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function findUserByOderId(oderId: string) {
|
||||
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
|
||||
}
|
||||
|
||||
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
|
||||
let voiceActiveMatch: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
|
||||
voiceActiveMatch = user;
|
||||
}
|
||||
});
|
||||
|
||||
return voiceActiveMatch;
|
||||
}
|
||||
|
||||
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
|
||||
let match: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
|
||||
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||
usersShareServerMembership: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
vi.mock('../services/session-auth.service', () => ({
|
||||
consumeSessionToken: vi.fn(async (token: string) => {
|
||||
if (token !== 'test-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: Date.now()
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||
requirements: [],
|
||||
eventDefinitions: []
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => { ws.closeCalled = true; },
|
||||
terminate: () => { ws.closeCalled = true; },
|
||||
closeCalled: false,
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Alice',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getSentMessages(user: ConnectedUser): string[] {
|
||||
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
}
|
||||
|
||||
describe('server websocket handler - multi-client sessions', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('relays voice_state to other connections for the same user', async () => {
|
||||
const sender = createConnectedUser('conn-a1', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
const passive = createConnectedUser('conn-a2', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
getSentMessages(passive).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-a1', {
|
||||
type: 'voice_state',
|
||||
serverId: 'server-1',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1',
|
||||
clientInstanceId: 'device-a'
|
||||
}
|
||||
});
|
||||
|
||||
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
|
||||
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||
|
||||
expect(voiceState).toBeDefined();
|
||||
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
|
||||
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
|
||||
});
|
||||
|
||||
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||
const sender = createConnectedUser('conn-sender', {
|
||||
authenticated: true,
|
||||
oderId: 'user-2',
|
||||
serverIds: new Set(['server-1'])
|
||||
});
|
||||
createConnectedUser('conn-passive', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-passive'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-active'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-sender', {
|
||||
type: 'offer',
|
||||
targetUserId: 'user-1',
|
||||
serverId: 'server-1',
|
||||
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
|
||||
|
||||
expect(messages.some((message) => message.type === 'offer')).toBe(true);
|
||||
});
|
||||
|
||||
it('relays voice_client_takeover to other connections for the same user', async () => {
|
||||
createConnectedUser('conn-requester', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-requester', {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
|
||||
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
|
||||
|
||||
expect(takeover?.clientInstanceId).toBe('device-b');
|
||||
});
|
||||
|
||||
it('evicts a stale connection with the same identity scope and client instance', async () => {
|
||||
const stale = createConnectedUser('conn-stale', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
createConnectedUser('conn-new', {
|
||||
authenticated: false,
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
await handleWebSocketMessage('conn-new', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
expect(connectedUsers.has('conn-stale')).toBe(false);
|
||||
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
isOderIdConnectedToServer,
|
||||
notifyOtherConnectionsForOderId
|
||||
} from './broadcast';
|
||||
import {
|
||||
authorizeWebSocketJoin,
|
||||
@@ -72,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClientInstanceId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function readVoiceConnected(message: WsMessage): boolean {
|
||||
const voiceState = message['voiceState'];
|
||||
|
||||
if (!voiceState || typeof voiceState !== 'object') {
|
||||
return message['isConnected'] === true;
|
||||
}
|
||||
|
||||
return (voiceState as { isConnected?: boolean }).isConnected === true;
|
||||
}
|
||||
|
||||
function evictStaleClientInstanceConnections(
|
||||
oderId: string,
|
||||
connectionScope: string | undefined,
|
||||
clientInstanceId: string | undefined,
|
||||
keepConnectionId: string
|
||||
): void {
|
||||
if (!clientInstanceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (
|
||||
connectionId === keepConnectionId
|
||||
|| candidate.oderId !== oderId
|
||||
|| candidate.connectionScope !== connectionScope
|
||||
|| candidate.clientInstanceId !== clientInstanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
candidate.ws.close();
|
||||
} catch {
|
||||
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidate.voiceActive = false;
|
||||
connectedUsers.set(connectionId, candidate);
|
||||
});
|
||||
}
|
||||
|
||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'voice_state',
|
||||
...snapshot
|
||||
}));
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -198,13 +267,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
|
||||
const newOderId = session.user.id;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
const newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.authenticated = true;
|
||||
user.clientInstanceId = newClientInstanceId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||
@@ -223,6 +296,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||
otherConnectionId !== connectionId
|
||||
&& otherUser.oderId === newOderId
|
||||
&& otherUser.voiceActive
|
||||
&& otherUser.voiceStateSnapshot
|
||||
)?.[1]?.voiceStateSnapshot;
|
||||
|
||||
if (voiceSnapshot) {
|
||||
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
|
||||
}
|
||||
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
@@ -240,7 +324,7 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -287,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId: sid
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -338,7 +422,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
serverId: leaveSid,
|
||||
serverIds: remainingServerIds
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,7 +478,7 @@ async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
|
||||
if (chatSid && user.serverIds.has(chatSid)) {
|
||||
@@ -404,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
message: message['message'],
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, { excludeConnectionId: connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!serverId || !user.serverIds.has(serverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConnected = readVoiceConnected(message);
|
||||
|
||||
if (isConnected) {
|
||||
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||
user.voiceActive = true;
|
||||
user.voiceStateSnapshot = {
|
||||
...message,
|
||||
type: 'voice_state',
|
||||
serverId,
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
};
|
||||
} else {
|
||||
user.voiceActive = false;
|
||||
user.voiceStateSnapshot = undefined;
|
||||
}
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
@@ -425,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
notifyOtherConnectionsForOderId(user.oderId, {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
|
||||
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
|
||||
}, connectionId);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||
const isTyping = message['isTyping'] !== false;
|
||||
@@ -443,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
channelId,
|
||||
isTyping,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -475,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
||||
oderId: user.oderId,
|
||||
status
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -520,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
|
||||
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
const pluginId = readMessageId(message['pluginId']);
|
||||
const eventName = readMessageId(message['eventName']);
|
||||
@@ -565,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
sourceUserId: user.oderId,
|
||||
emittedAt: Date.now()
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, message);
|
||||
@@ -623,15 +736,19 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'chat_message':
|
||||
handleChatMessage(user, message);
|
||||
handleChatMessage(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_state':
|
||||
handleVoiceState(user, message);
|
||||
handleVoiceState(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_client_takeover':
|
||||
handleVoiceClientTakeover(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
handleTyping(user, message);
|
||||
handleTyping(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'status_update':
|
||||
@@ -647,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
await handlePluginEvent(user, message, connectionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
}, { excludeIdentityOderId: user.oderId });
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface ConnectedUser {
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
serverIconUpdatedAtByServerId?: Map<string, number>;
|
||||
/** Stable per-install client id sent by the product client. */
|
||||
clientInstanceId?: string;
|
||||
/** Whether this connection currently owns active voice/WebRTC for the user. */
|
||||
voiceActive?: boolean;
|
||||
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
|
||||
voiceStateSnapshot?: Record<string, unknown>;
|
||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user