406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
/* 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 { 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<ServerInfo[]> {
|
|
if (shouldSearchAllServers) {
|
|
return this.searchAllEndpoints(query);
|
|
}
|
|
|
|
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
|
}
|
|
|
|
getServers(shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
|
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<ServerInfo | null> {
|
|
return this.http
|
|
.get<ServerInfo>(`${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<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerInfo> {
|
|
return this.http
|
|
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to register server:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
updateServer(
|
|
serverId: string,
|
|
updates: Partial<ServerInfo> & {
|
|
currentOwnerId: string;
|
|
actingRole?: string;
|
|
password?: string | null;
|
|
},
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerInfo> {
|
|
return this.http
|
|
.put<ServerInfo>(`${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<void> {
|
|
return this.http
|
|
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to unregister server:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
|
return this.http
|
|
.get<User[]>(`${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<ServerJoinAccessResponse> {
|
|
return this.http
|
|
.post<ServerJoinAccessResponse>(
|
|
`${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<ServerInviteInfo> {
|
|
return this.http
|
|
.post<ServerInviteInfo>(`${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<ServerInviteInfo> {
|
|
return this.http
|
|
.get<ServerInviteInfo>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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<void> {
|
|
return this.http
|
|
.patch<void>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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<ServerInfo[]> {
|
|
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<ServerInfo[]> {
|
|
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<ServerInfo[]> {
|
|
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<T extends { id: string }>(items: T[]): T[] {
|
|
const seen = new Set<string>();
|
|
|
|
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<string, unknown>,
|
|
source?: ServerEndpoint | null
|
|
): ServerInfo {
|
|
const candidate = server as Record<string, unknown>;
|
|
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.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 getStringValue(value: unknown): string | undefined {
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
}
|