/* eslint-disable @typescript-eslint/no-invalid-void-type */ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, forkJoin, of, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ChannelPermissionOverride, type Channel, ROOM_PERMISSION_KEYS, RoomRole, RoomRoleAssignment, User } from '../../../shared-kernel'; import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; import type { BanServerMemberRequest, CreateServerInviteRequest, KickServerMemberRequest, ServerEndpoint, ServerInfo, ServerInviteInfo, ServerJoinAccessRequest, ServerJoinAccessResponse, ServerSourceSelector, UnbanServerMemberRequest } from '../domain/server-directory.models'; @Injectable({ providedIn: 'root' }) export class ServerDirectoryApiService { private readonly http = inject(HttpClient); private readonly endpointState = inject(ServerEndpointStateService); getApiBaseUrl(selector?: ServerSourceSelector): string { return `${this.resolveBaseServerUrl(selector)}/api`; } getWebSocketUrl(selector?: ServerSourceSelector): string { return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); } resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { if (selector?.sourceId) { return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; } if (selector?.sourceUrl) { return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null; } return ( this.endpointState.activeServer() ?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ?? this.endpointState.servers()[0] ?? null ); } searchServers(query: string, shouldSearchAllServers: boolean): Observable { if (shouldSearchAllServers) { return this.searchAllEndpoints(query); } return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); } getServers(shouldSearchAllServers: boolean): Observable { if (shouldSearchAllServers) { return this.getAllServersFromAllEndpoints(); } return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe( map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), catchError((error) => { console.error('Failed to get servers:', error); return of([]); }) ); } getServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http.get(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe( map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), catchError((error) => { console.error('Failed to get server:', error); return of(null); }) ); } registerServer( server: Omit & { id?: string; password?: string | null }, selector?: ServerSourceSelector ): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers`, server).pipe( catchError((error) => { console.error('Failed to register server:', error); return throwError(() => error); }) ); } updateServer( serverId: string, updates: Partial & { currentOwnerId: string; actingRole?: string; password?: string | null; }, selector?: ServerSourceSelector ): Observable { return this.http.put(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates).pipe( catchError((error) => { console.error('Failed to update server:', error); return throwError(() => error); }) ); } unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http.delete(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe( catchError((error) => { console.error('Failed to unregister server:', error); return throwError(() => error); }) ); } getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { return this.http.get(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`).pipe( catchError((error) => { console.error('Failed to get server users:', error); return of([]); }) ); } requestJoin(request: ServerJoinAccessRequest, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, request).pipe( catchError((error) => { console.error('Failed to send join request:', error); return throwError(() => error); }) ); } createInvite(serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request).pipe( catchError((error) => { console.error('Failed to create invite:', error); return throwError(() => error); }) ); } getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { return this.http.get(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`).pipe( catchError((error) => { console.error('Failed to get invite:', error); return throwError(() => error); }) ); } kickServerMember(serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request).pipe( catchError((error) => { console.error('Failed to kick server member:', error); return throwError(() => error); }) ); } banServerMember(serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request).pipe( catchError((error) => { console.error('Failed to ban server member:', error); return throwError(() => error); }) ); } unbanServerMember(serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request).pipe( catchError((error) => { console.error('Failed to unban server member:', error); return throwError(() => error); }) ); } notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { return this.http.post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }).pipe( catchError((error) => { console.error('Failed to notify leave:', error); return of(undefined); }) ); } updateUserCount(serverId: string, count: number): Observable { return this.http.patch(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }).pipe( catchError((error) => { console.error('Failed to update user count:', error); return of(undefined); }) ); } sendHeartbeat(serverId: string): Observable { return this.http.post(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}).pipe( catchError((error) => { console.error('Failed to send heartbeat:', error); return of(undefined); }) ); } private resolveBaseServerUrl(selector?: ServerSourceSelector): string { if (selector?.sourceUrl) { return this.endpointState.sanitiseUrl(selector.sourceUrl); } return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl(); } private unwrapServersResponse(response: { servers: ServerInfo[]; total: number } | ServerInfo[]): ServerInfo[] { return Array.isArray(response) ? response : (response.servers ?? []); } private searchSingleEndpoint(query: string, apiBaseUrl: string, source?: ServerEndpoint | null): Observable { const params = new HttpParams().set('q', query); return this.http.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }).pipe( map((response) => this.normalizeServerList(response, source)), catchError((error) => { console.error('Failed to search servers:', error); return of([]); }) ); } private searchAllEndpoints(query: string): Observable { const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline'); if (onlineEndpoints.length === 0) { return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); } return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe( map((resultArrays) => resultArrays.flat()), map((servers) => this.deduplicateById(servers)) ); } private getAllServersFromAllEndpoints(): Observable { const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline'); if (onlineEndpoints.length === 0) { return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe( map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), catchError(() => of([])) ); } return forkJoin( onlineEndpoints.map((endpoint) => this.http.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`).pipe( map((response) => this.normalizeServerList(response, endpoint)), catchError(() => of([] as ServerInfo[])) ) ) ).pipe(map((resultArrays) => resultArrays.flat())); } private deduplicateById(items: T[]): T[] { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) { return false; } seen.add(item.id); return true; }); } private normalizeServerList(response: { servers: ServerInfo[]; total: number } | ServerInfo[], source?: ServerEndpoint | null): ServerInfo[] { return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); } private normalizeServerInfo(server: ServerInfo | Record, source?: ServerEndpoint | null): ServerInfo { const candidate = server as Record; const sourceName = this.getStringValue(candidate['sourceName']); const sourceUrl = this.getStringValue(candidate['sourceUrl']); return { id: this.getStringValue(candidate['id']) ?? '', name: this.getStringValue(candidate['name']) ?? 'Unnamed server', description: this.getStringValue(candidate['description']), topic: this.getStringValue(candidate['topic']), hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API', ownerId: this.getStringValue(candidate['ownerId']), ownerName: this.getStringValue(candidate['ownerName']), ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']), userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])), maxUsers: this.getNumberValue(candidate['maxUsers']), hasPassword: this.getBooleanValue(candidate['hasPassword']), isPrivate: this.getBooleanValue(candidate['isPrivate']), tags: Array.isArray(candidate['tags']) ? (candidate['tags'] as string[]) : [], channels: this.getChannelsValue(candidate['channels']), slowModeInterval: this.getNumberValue(candidate['slowModeInterval'], 0), roles: this.getRolesValue(candidate['roles']), roleAssignments: this.getRoleAssignmentsValue(candidate['roleAssignments']), channelPermissions: this.getChannelPermissionOverridesValue(candidate['channelPermissions']), createdAt: this.getNumberValue(candidate['createdAt'], Date.now()), sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, sourceName: sourceName ?? source?.name, sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : source ? this.endpointState.sanitiseUrl(source.url) : undefined }; } private getBooleanValue(value: unknown): boolean { return typeof value === 'boolean' ? value : value === 1; } private getNumberValue(value: unknown, fallback = 0): number { return typeof value === 'number' ? value : fallback; } private getChannelsValue(value: unknown): Channel[] | undefined { if (!Array.isArray(value)) { return undefined; } return value .filter((channel): channel is Record => !!channel && typeof channel === 'object') .map((channel, index) => { const id = this.getStringValue(channel['id']); const name = this.getStringValue(channel['name']); const type = this.getChannelTypeValue(channel['type']); const position = this.getNumberValue(channel['position'], index); if (!id || !name || !type) { return null; } return { id, name, type, position } satisfies Channel; }) .filter((channel): channel is Channel => !!channel); } private getRolesValue(value: unknown): RoomRole[] | undefined { if (!Array.isArray(value)) { return undefined; } return value .filter((role): role is Record => !!role && typeof role === 'object') .flatMap((role, index) => { const id = this.getStringValue(role['id']); const name = this.getStringValue(role['name']); const position = this.getNumberValue(role['position'], index * 100); if (!id || !name) { return []; } const normalizedRole: RoomRole = { id, name, position, permissions: this.getPermissionMatrixValue(role['permissions']) }; const color = this.getStringValue(role['color']); const isSystem = typeof role['isSystem'] === 'boolean' ? role['isSystem'] : this.getBooleanValue(role['isSystem']); if (color) { normalizedRole.color = color; } if (typeof isSystem === 'boolean') { normalizedRole.isSystem = isSystem; } return [normalizedRole]; }); } private getRoleAssignmentsValue(value: unknown): RoomRoleAssignment[] | undefined { if (!Array.isArray(value)) { return undefined; } return value .filter((assignment): assignment is Record => !!assignment && typeof assignment === 'object') .flatMap((assignment) => { const userId = this.getStringValue(assignment['userId']); const roleIds = Array.isArray(assignment['roleIds']) ? assignment['roleIds'].filter((roleId): roleId is string => typeof roleId === 'string') : []; if (!userId || roleIds.length === 0) { return []; } const normalizedAssignment: RoomRoleAssignment = { userId, roleIds }; const oderId = this.getStringValue(assignment['oderId']); if (oderId) { normalizedAssignment.oderId = oderId; } return [normalizedAssignment]; }); } private getChannelPermissionOverridesValue(value: unknown): ChannelPermissionOverride[] | undefined { if (!Array.isArray(value)) { return undefined; } return value .filter((override): override is Record => !!override && typeof override === 'object') .map((override) => { const channelId = this.getStringValue(override['channelId']); const targetId = this.getStringValue(override['targetId']); const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : undefined; const permission = ROOM_PERMISSION_KEYS.find((candidatePermission) => candidatePermission === override['permission']); const valueState = override['value'] === 'allow' || override['value'] === 'deny' || override['value'] === 'inherit' ? override['value'] : undefined; if (!channelId || !targetId || !targetType || !permission || !valueState) { return null; } return { channelId, targetId, targetType, permission, value: valueState } satisfies ChannelPermissionOverride; }) .filter((override): override is ChannelPermissionOverride => !!override); } private getPermissionMatrixValue(value: unknown): RoomRole['permissions'] { if (!value || typeof value !== 'object') { return undefined; } const matrix = value as Record; const normalized = ROOM_PERMISSION_KEYS.reduce>((nextMatrix, permission) => { const permissionValue = matrix[permission]; if (permissionValue === 'allow' || permissionValue === 'deny' || permissionValue === 'inherit') { nextMatrix[permission] = permissionValue; } return nextMatrix; }, {}); return Object.keys(normalized).length > 0 ? normalized : undefined; } private getChannelTypeValue(value: unknown): Channel['type'] | undefined { return value === 'text' || value === 'voice' ? value : undefined; } private getStringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } }