Private servers with password and invite links (Experimental)
This commit is contained in:
@@ -12,11 +12,7 @@ import {
|
||||
forkJoin
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import {
|
||||
ServerInfo,
|
||||
JoinRequest,
|
||||
User
|
||||
} from '../models/index';
|
||||
import { ServerInfo, User } from '../models/index';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@@ -40,6 +36,69 @@ export interface ServerEndpoint {
|
||||
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. */
|
||||
@@ -131,7 +190,7 @@ export class ServerDirectoryService {
|
||||
*
|
||||
* @param server - Name and URL of the endpoint to add.
|
||||
*/
|
||||
addServer(server: { name: string; url: string }): void {
|
||||
addServer(server: { name: string; url: string }): ServerEndpoint {
|
||||
const sanitisedUrl = this.sanitiseUrl(server.url);
|
||||
const newEndpoint: ServerEndpoint = {
|
||||
id: uuidv4(),
|
||||
@@ -144,6 +203,40 @@ export class ServerDirectoryService {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,18 +358,13 @@ export class ServerDirectoryService {
|
||||
}
|
||||
|
||||
/** Expose the API base URL for external consumers. */
|
||||
getApiBaseUrl(): string {
|
||||
return this.buildApiBaseUrl();
|
||||
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return this.buildApiBaseUrl(selector);
|
||||
}
|
||||
|
||||
/** Get the WebSocket URL derived from the active endpoint. */
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
|
||||
if (!active)
|
||||
return buildDefaultServerUrl().replace(/^http/, 'ws');
|
||||
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
||||
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,11 +398,11 @@ export class ServerDirectoryService {
|
||||
}
|
||||
|
||||
/** Fetch details for a single server. */
|
||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||
.get<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.activeServer())),
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
@@ -324,10 +412,11 @@ export class ServerDirectoryService {
|
||||
|
||||
/** Register a new server listing in the directory. */
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string }
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
|
||||
.post<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
@@ -339,10 +428,15 @@ export class ServerDirectoryService {
|
||||
/** Update an existing server listing. */
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & { currentOwnerId: string }
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||
.put<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
@@ -352,9 +446,9 @@ export class ServerDirectoryService {
|
||||
}
|
||||
|
||||
/** Remove a server listing from the directory. */
|
||||
unregisterServer(serverId: string): Observable<void> {
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||
.delete<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
@@ -364,9 +458,9 @@ export class ServerDirectoryService {
|
||||
}
|
||||
|
||||
/** Retrieve users currently connected to a server. */
|
||||
getServerUsers(serverId: string): Observable<User[]> {
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
|
||||
.get<User[]>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
@@ -377,11 +471,12 @@ export class ServerDirectoryService {
|
||||
|
||||
/** Send a join request for a server and receive the signaling URL. */
|
||||
requestJoin(
|
||||
request: JoinRequest
|
||||
): Observable<{ success: boolean; signalingUrl?: string }> {
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.http
|
||||
.post<{ success: boolean; signalingUrl?: string }>(
|
||||
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
|
||||
.post<ServerJoinAccessResponse>(
|
||||
`${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
@@ -392,6 +487,82 @@ export class ServerDirectoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Create an expiring invite link for a server. */
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.post<ServerInviteInfo>(`${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<ServerInviteInfo> {
|
||||
return this.http
|
||||
.get<ServerInviteInfo>(`${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<void> {
|
||||
return this.http
|
||||
.post<void>(`${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<void> {
|
||||
return this.http
|
||||
.post<void>(`${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<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Notify the directory that a user has left a server. */
|
||||
notifyLeave(serverId: string, userId: string): Observable<void> {
|
||||
return this.http
|
||||
@@ -432,17 +603,8 @@ export class ServerDirectoryService {
|
||||
* Build the active endpoint's API base URL, stripping trailing
|
||||
* slashes and accidental `/api` suffixes.
|
||||
*/
|
||||
private buildApiBaseUrl(): string {
|
||||
const active = this.activeServer();
|
||||
const rawUrl = active ? active.url : buildDefaultServerUrl();
|
||||
|
||||
let base = rawUrl.replace(/\/+$/, '');
|
||||
|
||||
if (base.toLowerCase().endsWith('/api')) {
|
||||
base = base.slice(0, -4);
|
||||
}
|
||||
|
||||
return `${base}/api`;
|
||||
private buildApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return `${this.resolveBaseServerUrl(selector)}/api`;
|
||||
}
|
||||
|
||||
/** Strip trailing slashes and `/api` suffix from a URL. */
|
||||
@@ -456,6 +618,26 @@ export class ServerDirectoryService {
|
||||
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.
|
||||
@@ -560,45 +742,44 @@ export class ServerDirectoryService {
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const userCount = typeof candidate['userCount'] === 'number'
|
||||
? candidate['userCount']
|
||||
: (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0);
|
||||
const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0;
|
||||
const isPrivate = typeof candidate['isPrivate'] === 'boolean'
|
||||
? candidate['isPrivate']
|
||||
: candidate['isPrivate'] === 1;
|
||||
const sourceName = this.getStringValue(candidate['sourceName']);
|
||||
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
||||
|
||||
return {
|
||||
id: typeof candidate['id'] === 'string' ? candidate['id'] : '',
|
||||
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server',
|
||||
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined,
|
||||
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined,
|
||||
hostName:
|
||||
typeof candidate['hostName'] === 'string'
|
||||
? candidate['hostName']
|
||||
: (typeof candidate['sourceName'] === 'string'
|
||||
? candidate['sourceName']
|
||||
: (source?.name ?? 'Unknown API')),
|
||||
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined,
|
||||
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined,
|
||||
ownerPublicKey:
|
||||
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
|
||||
userCount,
|
||||
maxUsers,
|
||||
isPrivate,
|
||||
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: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(),
|
||||
sourceId:
|
||||
typeof candidate['sourceId'] === 'string'
|
||||
? candidate['sourceId']
|
||||
: source?.id,
|
||||
sourceName:
|
||||
typeof candidate['sourceName'] === 'string'
|
||||
? candidate['sourceName']
|
||||
: source?.name
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user