feat: server image

This commit is contained in:
2026-04-29 18:54:08 +02:00
parent 3d81c34159
commit e1ac1d1bc0
27 changed files with 1340 additions and 615 deletions

View File

@@ -18,6 +18,8 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
icon: server.icon ?? null,
iconUpdatedAt: server.iconUpdatedAt ?? 0,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen

View File

@@ -47,6 +47,8 @@ export function rowToServer(
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt || undefined,
slowModeInterval: relationPayload.slowModeInterval,
tags: relationPayload.tags,
channels: relationPayload.channels,

View File

@@ -86,6 +86,8 @@ export interface ServerPayload {
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
icon?: string;
iconUpdatedAt?: number;
slowModeInterval?: number;
tags: string[];
channels: ServerChannelPayload[];

View File

@@ -33,6 +33,12 @@ export class ServerEntity {
@Column('integer', { default: 0 })
currentUsers!: number;
@Column('text', { nullable: true })
icon!: string | null;
@Column('integer', { default: 0 })
iconUpdatedAt!: number;
@Column('integer', { default: 0 })
slowModeInterval!: number;

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerIcons1000000000009 implements MigrationInterface {
name = 'ServerIcons1000000000009';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "icon" TEXT`);
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "iconUpdatedAt" INTEGER NOT NULL DEFAULT 0`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "servers_without_icons" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"passwordHash" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)`);
await queryRunner.query(`INSERT INTO "servers_without_icons" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen" FROM "servers"`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_without_icons" RENAME TO "servers"`);
}
}

View File

@@ -7,6 +7,7 @@ import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRole
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -17,5 +18,6 @@ export const serverMigrations = [
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006,
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008
ServerPluginInstallMetadata1000000000008,
ServerIcons1000000000009
];

View File

@@ -166,7 +166,9 @@ router.post('/', async (req, res) => {
maxUsers,
password,
tags,
channels
channels,
icon,
iconUpdatedAt
} = req.body;
if (!name || !ownerId || !ownerPublicKey)
@@ -184,6 +186,8 @@ router.post('/', async (req, res) => {
isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0,
currentUsers: 0,
icon: typeof icon === 'string' ? icon : undefined,
iconUpdatedAt: typeof iconUpdatedAt === 'number' ? iconUpdatedAt : undefined,
tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(),

View File

@@ -1,18 +1,8 @@
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import {
broadcastToServer,
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service';
import {
getPluginRequirementsSnapshot,
PluginSupportError,
validatePluginEventEnvelope
} from '../services/plugin-support.service';
import { getPluginRequirementsSnapshot, PluginSupportError, validatePluginEventEnvelope } from '../services/plugin-support.service';
interface WsMessage {
[key: string]: unknown;
@@ -36,9 +26,7 @@ function normalizeDescription(value: unknown): string | undefined {
}
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: undefined;
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
}
function readMessageId(value: unknown): string | undefined {
@@ -57,37 +45,40 @@ function readMessageId(value: unknown): string | undefined {
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
if (error instanceof PluginSupportError) {
user.ws.send(JSON.stringify({
type: 'plugin_error',
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: error.code,
message: error.message
}));
user.ws.send(
JSON.stringify({
type: 'plugin_error',
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: error.code,
message: error.message
})
);
return;
}
console.error('Unhandled plugin websocket error:', error);
user.ws.send(JSON.stringify({
type: 'plugin_error',
code: 'INTERNAL_ERROR',
message: 'Internal server error'
}));
user.ws.send(
JSON.stringify({
type: 'plugin_error',
code: 'INTERNAL_ERROR',
message: 'Internal server error'
})
);
}
/** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = getUniqueUsersInServer(serverId, user.oderId)
.map(cu => ({
oderId: cu.oderId,
displayName: normalizeDisplayName(cu.displayName),
description: cu.description,
profileUpdatedAt: cu.profileUpdatedAt,
status: cu.status ?? 'online'
}));
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => ({
oderId: cu.oderId,
displayName: normalizeDisplayName(cu.displayName),
description: cu.description,
profileUpdatedAt: cu.profileUpdatedAt,
status: cu.status ?? 'online'
}));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
}
@@ -96,11 +87,13 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
try {
const snapshot = await getPluginRequirementsSnapshot(serverId);
user.ws.send(JSON.stringify({
type: 'plugin_requirements',
serverId,
snapshot
}));
user.ws.send(
JSON.stringify({
type: 'plugin_requirements',
serverId,
snapshot
})
);
} catch (error) {
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
}
@@ -128,41 +121,42 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`);
if (
user.displayName === previousDisplayName
&& user.description === previousDescription
&& user.profileUpdatedAt === previousProfileUpdatedAt
) {
if (user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt) {
return;
}
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId
}, user.oderId);
broadcastToServer(
serverId,
{
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId
},
user.oderId
);
}
}
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = readMessageId(message['serverId']);
if (!sid)
return;
if (!sid) return;
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
if (!authorization.allowed) {
user.ws.send(JSON.stringify({
type: 'access_denied',
serverId: sid,
reason: authorization.reason
}));
user.ws.send(
JSON.stringify({
type: 'access_denied',
serverId: sid,
reason: authorization.reason
})
);
return;
}
@@ -174,31 +168,34 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
user.viewedServerId = sid;
connectedUsers.set(connectionId, user);
console.log(
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` +
`(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
);
sendServerUsers(user, sid);
await sendPluginRequirements(user, sid);
if (isNewIdentityMembership) {
broadcastToServer(sid, {
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId: sid
}, user.oderId);
broadcastToServer(
sid,
{
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId: sid
},
user.oderId
);
}
}
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const viewSid = readMessageId(message['serverId']);
if (!viewSid)
return;
if (!viewSid) return;
if (!user.serverIds.has(viewSid)) {
return;
@@ -215,13 +212,11 @@ async function handleViewServer(user: ConnectedUser, message: WsMessage, connect
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!leaveSid)
return;
if (!leaveSid) return;
user.serverIds.delete(leaveSid);
if (user.viewedServerId === leaveSid)
user.viewedServerId = undefined;
if (user.viewedServerId === leaveSid) user.viewedServerId = undefined;
connectedUsers.set(connectionId, user);
@@ -231,13 +226,17 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
return;
}
broadcastToServer(leaveSid, {
type: 'user_left',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid,
serverIds: remainingServerIds
}, user.oderId);
broadcastToServer(
leaveSid,
{
type: 'user_left',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid,
serverIds: remainingServerIds
},
user.oderId
);
}
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
@@ -253,7 +252,7 @@ function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
} else {
console.log(
`Target user ${targetUserId} not found. Connected users:`,
Array.from(connectedUsers.values()).map(cu => ({ oderId: cu.oderId, displayName: cu.displayName }))
Array.from(connectedUsers.values()).map((cu) => ({ oderId: cu.oderId, displayName: cu.displayName }))
);
}
}
@@ -275,62 +274,104 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing',
serverId: typingSid,
channelId,
oderId: user.oderId,
displayName: user.displayName
}, user.oderId);
broadcastToServer(
typingSid,
{
type: 'user_typing',
serverId: typingSid,
channelId,
oderId: user.oderId,
displayName: user.displayName
},
user.oderId
);
}
}
const VALID_STATUSES = new Set([
'online',
'away',
'busy',
'offline'
]);
const VALID_STATUSES = new Set(['online', 'away', 'busy', 'offline']);
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
if (!status || !VALID_STATUSES.has(status))
return;
if (!status || !VALID_STATUSES.has(status)) return;
user.status = status as ConnectedUser['status'];
connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'status_update',
oderId: user.oderId,
status
}, user.oderId);
broadcastToServer(
serverId,
{
type: 'status_update',
oderId: user.oderId,
status
},
user.oderId
);
}
}
function handleServerIconAvailable(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const serverId = readMessageId(message['serverId']);
const iconUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0;
if (!serverId || iconUpdatedAt <= 0 || !user.serverIds.has(serverId)) {
return;
}
const availableIcons = user.serverIconUpdatedAtByServerId ?? new Map<string, number>();
availableIcons.set(serverId, iconUpdatedAt);
user.serverIconUpdatedAtByServerId = availableIcons;
connectedUsers.set(connectionId, user);
}
function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): void {
const serverId = readMessageId(message['serverId']);
const localUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0;
if (!serverId) {
return;
}
const users = getUniqueUsersInServer(serverId, user.oderId)
.filter((candidate) => (candidate.serverIconUpdatedAtByServerId?.get(serverId) ?? 0) > localUpdatedAt)
.map((candidate) => ({
oderId: candidate.oderId,
displayName: normalizeDisplayName(candidate.displayName),
description: candidate.description,
profileUpdatedAt: candidate.profileUpdatedAt,
status: candidate.status ?? 'online'
}));
if (users.length === 0) {
return;
}
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
}
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
const pluginId = readMessageId(message['pluginId']);
const eventName = readMessageId(message['eventName']);
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
user.ws.send(JSON.stringify({
type: 'plugin_error',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: 'INVALID_PLUGIN_EVENT',
message: 'Plugin event is missing required fields or server membership'
}));
user.ws.send(
JSON.stringify({
type: 'plugin_error',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: 'INVALID_PLUGIN_EVENT',
message: 'Plugin event is missing required fields or server membership'
})
);
return;
}
@@ -346,17 +387,21 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
});
broadcastToServer(serverId, {
type: 'plugin_event',
broadcastToServer(
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
payload: message['payload'],
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
sourceUserId: user.oderId,
emittedAt: Date.now()
}, user.oderId);
{
type: 'plugin_event',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
payload: message['payload'],
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
sourceUserId: user.oderId,
emittedAt: Date.now()
},
user.oderId
);
} catch (error) {
sendPluginError(user, error, message);
}
@@ -365,8 +410,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId);
if (!user)
return;
if (!user) return;
user.lastPong = Date.now();
connectedUsers.set(connectionId, user);
@@ -394,6 +438,8 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
case 'offer':
case 'answer':
case 'ice_candidate':
case 'server_icon_peer_request':
case 'server_icon_peer_data':
forwardRtcMessage(user, message);
break;
@@ -409,6 +455,14 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleStatusUpdate(user, message, connectionId);
break;
case 'server_icon_available':
handleServerIconAvailable(user, message, connectionId);
break;
case 'server_icon_sync_request':
handleServerIconSyncRequest(user, message);
break;
case 'plugin_event':
await handlePluginEvent(user, message);
break;

View File

@@ -17,6 +17,8 @@ export interface ConnectedUser {
connectionScope?: string;
/** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline';
/** Latest server icon timestamp this connection can provide over P2P. */
serverIconUpdatedAtByServerId?: Map<string, number>;
/** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number;
}