/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ import { Injectable, signal, computed } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, of, throwError, forkJoin } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ServerInfo, User } from '../models/index'; import { v4 as uuidv4 } from 'uuid'; import { environment } from '../../../environments/environment'; /** * A configured server endpoint that the user can connect to. */ export interface ServerEndpoint { /** Unique endpoint identifier. */ id: string; /** Human-readable label shown in the UI. */ name: string; /** Base URL (e.g. `http://localhost:3001`). */ url: string; /** Whether this is the currently selected endpoint. */ isActive: boolean; /** Whether this is the built-in default endpoint. */ isDefault: boolean; /** Most recent health-check result. */ status: 'online' | 'offline' | 'checking' | 'unknown'; /** Last measured round-trip latency (ms). */ latency?: number; } export interface ServerSourceSelector { sourceId?: string; sourceUrl?: string; } export interface ServerJoinAccessRequest { roomId: string; userId: string; userPublicKey: string; displayName: string; password?: string; inviteId?: string; } export interface ServerJoinAccessResponse { success: boolean; signalingUrl: string; joinedBefore: boolean; via: 'membership' | 'password' | 'invite' | 'public'; server: ServerInfo; } export interface CreateServerInviteRequest { requesterUserId: string; requesterDisplayName?: string; requesterRole?: string; } export interface ServerInviteInfo { id: string; serverId: string; createdAt: number; expiresAt: number; inviteUrl: string; browserUrl: string; appUrl: string; sourceUrl: string; createdBy?: string; createdByDisplayName?: string; isExpired: boolean; server: ServerInfo; } export interface KickServerMemberRequest { actorUserId: string; actorRole?: string; targetUserId: string; } export interface BanServerMemberRequest extends KickServerMemberRequest { banId?: string; displayName?: string; reason?: string; expiresAt?: number; } export interface UnbanServerMemberRequest { actorUserId: string; actorRole?: string; banId?: string; targetUserId?: string; } /** localStorage key that persists the user's configured endpoints. */ const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; /** Timeout (ms) for server health-check and alternative-endpoint pings. */ const HEALTH_CHECK_TIMEOUT_MS = 5000; function getDefaultHttpProtocol(): 'http' | 'https' { return typeof window !== 'undefined' && window.location?.protocol === 'https:' ? 'https' : 'http'; } function normaliseDefaultServerUrl(rawUrl: string): string { let cleaned = rawUrl.trim(); if (!cleaned) return ''; if (cleaned.toLowerCase().startsWith('ws://')) { cleaned = `http://${cleaned.slice(5)}`; } else if (cleaned.toLowerCase().startsWith('wss://')) { cleaned = `https://${cleaned.slice(6)}`; } else if (cleaned.startsWith('//')) { cleaned = `${getDefaultHttpProtocol()}:${cleaned}`; } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) { cleaned = `${getDefaultHttpProtocol()}://${cleaned}`; } cleaned = cleaned.replace(/\/+$/, ''); if (cleaned.toLowerCase().endsWith('/api')) { cleaned = cleaned.slice(0, -4); } return cleaned; } /** * Derive the default server URL from the environment when provided, * otherwise match the current page protocol automatically. */ function buildDefaultServerUrl(): string { const configuredUrl = environment.defaultServerUrl?.trim(); if (configuredUrl) { return normaliseDefaultServerUrl(configuredUrl); } return `${getDefaultHttpProtocol()}://localhost:3001`; } /** Blueprint for the built-in default endpoint. */ const DEFAULT_ENDPOINT: Omit = { name: 'Default Server', url: buildDefaultServerUrl(), isActive: true, isDefault: true, status: 'unknown' }; /** * Manages the user's list of configured server endpoints and * provides an HTTP client for server-directory API calls * (search, register, join/leave, heartbeat, etc.). * * Endpoints are persisted in `localStorage` and exposed as * Angular signals for reactive consumption. */ @Injectable({ providedIn: 'root' }) export class ServerDirectoryService { private readonly _servers = signal([]); /** Whether search queries should be fanned out to all non-offline endpoints. */ private shouldSearchAllServers = false; /** Reactive list of all configured endpoints. */ readonly servers = computed(() => this._servers()); /** The currently active endpoint, falling back to the first in the list. */ readonly activeServer = computed( () => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0] ); constructor(private readonly http: HttpClient) { this.loadEndpoints(); } /** * Add a new server endpoint (inactive by default). * * @param server - Name and URL of the endpoint to add. */ addServer(server: { name: string; url: string }): ServerEndpoint { const sanitisedUrl = this.sanitiseUrl(server.url); const newEndpoint: ServerEndpoint = { id: uuidv4(), name: server.name, url: sanitisedUrl, isActive: false, isDefault: false, status: 'unknown' }; this._servers.update((endpoints) => [...endpoints, newEndpoint]); this.saveEndpoints(); return newEndpoint; } /** Ensure an endpoint exists for a given URL, optionally activating it. */ ensureServerEndpoint( server: { name: string; url: string }, options?: { setActive?: boolean } ): ServerEndpoint { const sanitisedUrl = this.sanitiseUrl(server.url); const existing = this.findServerByUrl(sanitisedUrl); if (existing) { if (options?.setActive) { this.setActiveServer(existing.id); } return existing; } const created = this.addServer({ name: server.name, url: sanitisedUrl }); if (options?.setActive) { this.setActiveServer(created.id); } return created; } /** Find a configured endpoint by URL. */ findServerByUrl(url: string): ServerEndpoint | undefined { const sanitisedUrl = this.sanitiseUrl(url); return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); } /** * Remove an endpoint by ID. * The built-in default endpoint cannot be removed. If the removed * endpoint was active, the first remaining endpoint is activated. */ removeServer(endpointId: string): void { const endpoints = this._servers(); const target = endpoints.find((endpoint) => endpoint.id === endpointId); if (target?.isDefault) return; const wasActive = target?.isActive; this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); if (wasActive) { this._servers.update((list) => { if (list.length > 0) list[0].isActive = true; return [...list]; }); } this.saveEndpoints(); } /** Activate a specific endpoint and deactivate all others. */ setActiveServer(endpointId: string): void { this._servers.update((endpoints) => endpoints.map((endpoint) => ({ ...endpoint, isActive: endpoint.id === endpointId })) ); this.saveEndpoints(); } /** Update the health status and optional latency of an endpoint. */ updateServerStatus( endpointId: string, status: ServerEndpoint['status'], latency?: number ): void { this._servers.update((endpoints) => endpoints.map((endpoint) => endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint ) ); this.saveEndpoints(); } /** Enable or disable fan-out search across all endpoints. */ setSearchAllServers(enabled: boolean): void { this.shouldSearchAllServers = enabled; } /** * Probe a single endpoint's health and update its status. * * @param endpointId - ID of the endpoint to test. * @returns `true` if the server responded successfully. */ async testServer(endpointId: string): Promise { const endpoint = this._servers().find((entry) => entry.id === endpointId); if (!endpoint) return false; this.updateServerStatus(endpointId, 'checking'); const startTime = Date.now(); try { const response = await fetch(`${endpoint.url}/api/health`, { method: 'GET', signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) }); const latency = Date.now() - startTime; if (response.ok) { this.updateServerStatus(endpointId, 'online', latency); return true; } this.updateServerStatus(endpointId, 'offline'); return false; } catch { // Fall back to the /servers endpoint try { const response = await fetch(`${endpoint.url}/api/servers`, { method: 'GET', signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) }); const latency = Date.now() - startTime; if (response.ok) { this.updateServerStatus(endpointId, 'online', latency); return true; } } catch { /* both checks failed */ } this.updateServerStatus(endpointId, 'offline'); return false; } } /** Probe all configured endpoints in parallel. */ async testAllServers(): Promise { const endpoints = this._servers(); await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); } /** Expose the API base URL for external consumers. */ getApiBaseUrl(selector?: ServerSourceSelector): string { return this.buildApiBaseUrl(selector); } /** Get the WebSocket URL derived from the active endpoint. */ getWebSocketUrl(selector?: ServerSourceSelector): string { return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); } /** * Search for public servers matching a query string. * When {@link shouldSearchAllServers} is `true`, the search is * fanned out to every non-offline endpoint. */ searchServers(query: string): Observable { if (this.shouldSearchAllServers) { return this.searchAllEndpoints(query); } return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } /** Retrieve the full list of public servers. */ getServers(): Observable { if (this.shouldSearchAllServers) { return this.getAllServersFromAllEndpoints(); } return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.activeServer())), catchError((error) => { console.error('Failed to get servers:', error); return of([]); }) ); } /** Fetch details for a single server. */ getServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) .pipe( map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), catchError((error) => { console.error('Failed to get server:', error); return of(null); }) ); } /** Register a new server listing in the directory. */ registerServer( server: Omit & { id?: string; password?: string | null }, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers`, server) .pipe( catchError((error) => { console.error('Failed to register server:', error); return throwError(() => error); }) ); } /** Update an existing server listing. */ updateServer( serverId: string, updates: Partial & { currentOwnerId: string; actingRole?: string; password?: string | null; }, selector?: ServerSourceSelector ): Observable { return this.http .put(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates) .pipe( catchError((error) => { console.error('Failed to update server:', error); return throwError(() => error); }) ); } /** Remove a server listing from the directory. */ unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .delete(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) .pipe( catchError((error) => { console.error('Failed to unregister server:', error); return throwError(() => error); }) ); } /** Retrieve users currently connected to a server. */ getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`) .pipe( catchError((error) => { console.error('Failed to get server users:', error); return of([]); }) ); } /** Send a join request for a server and receive the signaling URL. */ requestJoin( request: ServerJoinAccessRequest, selector?: ServerSourceSelector ): Observable { return this.http .post( `${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`, request ) .pipe( catchError((error) => { console.error('Failed to send join request:', error); return throwError(() => error); }) ); } /** Create an expiring invite link for a server. */ createInvite( serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request) .pipe( catchError((error) => { console.error('Failed to create invite:', error); return throwError(() => error); }) ); } /** Retrieve public invite metadata. */ getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`) .pipe( catchError((error) => { console.error('Failed to get invite:', error); return throwError(() => error); }) ); } /** Remove a member's stored join access for a server. */ kickServerMember( serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request) .pipe( catchError((error) => { console.error('Failed to kick server member:', error); return throwError(() => error); }) ); } /** Ban a member from a server invite/password access list. */ banServerMember( serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request) .pipe( catchError((error) => { console.error('Failed to ban server member:', error); return throwError(() => error); }) ); } /** Remove a stored server ban. */ unbanServerMember( serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request) .pipe( catchError((error) => { console.error('Failed to unban server member:', error); return throwError(() => error); }) ); } /** Remove a user's remembered membership after leaving a server. */ notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { return this.http .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) .pipe( catchError((error) => { console.error('Failed to notify leave:', error); return of(undefined); }) ); } /** Update the live user count for a server listing. */ updateUserCount(serverId: string, count: number): Observable { return this.http .patch(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count }) .pipe( catchError((error) => { console.error('Failed to update user count:', error); return of(undefined); }) ); } /** Send a heartbeat to keep the server listing active. */ sendHeartbeat(serverId: string): Observable { return this.http .post(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) .pipe( catchError((error) => { console.error('Failed to send heartbeat:', error); return of(undefined); }) ); } /** * Build the active endpoint's API base URL, stripping trailing * slashes and accidental `/api` suffixes. */ private buildApiBaseUrl(selector?: ServerSourceSelector): string { return `${this.resolveBaseServerUrl(selector)}/api`; } /** Strip trailing slashes and `/api` suffix from a URL. */ private sanitiseUrl(rawUrl: string): string { let cleaned = rawUrl.trim().replace(/\/+$/, ''); if (cleaned.toLowerCase().endsWith('/api')) { cleaned = cleaned.slice(0, -4); } return cleaned; } private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { if (selector?.sourceId) { return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; } if (selector?.sourceUrl) { return this.findServerByUrl(selector.sourceUrl) ?? null; } return this.activeServer() ?? this._servers()[0] ?? null; } private resolveBaseServerUrl(selector?: ServerSourceSelector): string { if (selector?.sourceUrl) { return this.sanitiseUrl(selector.sourceUrl); } return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl(); } /** * Handle both `{ servers: [...] }` and direct `ServerInfo[]` * response shapes from the directory API. */ private unwrapServersResponse( response: { servers: ServerInfo[]; total: number } | ServerInfo[] ): ServerInfo[] { if (Array.isArray(response)) return response; return response.servers ?? []; } /** Search a single endpoint for servers matching a query. */ 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([]); }) ); } /** Fan-out search across all non-offline endpoints, deduplicating results. */ private searchAllEndpoints(query: string): Observable { const onlineEndpoints = this._servers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } const requests = onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint) ); return forkJoin(requests).pipe( map((resultArrays) => resultArrays.flat()), map((servers) => this.deduplicateById(servers)) ); } /** Retrieve all servers from all non-offline endpoints. */ private getAllServersFromAllEndpoints(): Observable { const onlineEndpoints = this._servers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.activeServer())), catchError(() => of([])) ); } const requests = 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[])) ) ); return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat())); } /** Remove duplicate servers (by `id`), keeping the first occurrence. */ 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[] : [], createdAt: this.getNumberValue(candidate['createdAt'], Date.now()), sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, sourceName: sourceName ?? source?.name, sourceUrl: sourceUrl ? this.sanitiseUrl(sourceUrl) : (source ? this.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 getStringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ private loadEndpoints(): void { const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); if (!stored) { this.initialiseDefaultEndpoint(); return; } try { let endpoints = JSON.parse(stored) as ServerEndpoint[]; // Ensure at least one endpoint is active if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) { endpoints[0].isActive = true; } const defaultServerUrl = buildDefaultServerUrl(); endpoints = endpoints.map((endpoint) => { if (endpoint.isDefault) { return { ...endpoint, url: defaultServerUrl }; } return endpoint; }); this._servers.set(endpoints); this.saveEndpoints(); } catch { this.initialiseDefaultEndpoint(); } } /** Create and persist the built-in default endpoint. */ private initialiseDefaultEndpoint(): void { const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; this._servers.set([defaultEndpoint]); this.saveEndpoints(); } /** Persist the current endpoint list to localStorage. */ private saveEndpoints(): void { localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers())); } }