From 0ed9ca93d3062f603f69d5fa935169293455c8d2 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 4 Mar 2026 03:56:23 +0100 Subject: [PATCH] Refactor 4 with bugfixes --- README.md | 18 +- .../userstories-for-note-to-self.md | 0 server/package.json | 2 + server/src/app.ts | 14 + .../commands/handlers/createJoinRequest.ts | 19 + .../cqrs/commands/handlers/deleteServer.ts | 10 + .../handlers/deleteStaleJoinRequests.ts | 10 + .../cqrs/commands/handlers/registerUser.ts | 17 + .../handlers/updateJoinRequestStatus.ts | 10 + .../cqrs/commands/handlers/upsertServer.ts | 23 + server/src/cqrs/commands/index.ts | 27 + server/src/cqrs/index.ts | 66 ++ server/src/cqrs/mappers.ts | 46 ++ .../queries/handlers/getAllPublicServers.ts | 10 + .../queries/handlers/getJoinRequestById.ts | 11 + .../handlers/getPendingRequestsForServer.ts | 11 + .../cqrs/queries/handlers/getServerById.ts | 11 + .../src/cqrs/queries/handlers/getUserById.ts | 11 + .../queries/handlers/getUserByUsername.ts | 11 + server/src/cqrs/queries/index.ts | 26 + server/src/cqrs/types.ts | 144 +++++ server/src/db.ts | 367 ----------- server/src/db/database.ts | 59 ++ server/src/db/index.ts | 1 + server/src/db/types.ts | 31 + server/src/entities/AuthUserEntity.ts | 23 + server/src/entities/JoinRequestEntity.ts | 31 + server/src/entities/ServerEntity.ts | 41 ++ server/src/entities/index.ts | 3 + server/src/index.ts | 601 +----------------- .../migrations/1000000000000-InitialSchema.ts | 53 ++ server/src/routes/health.ts | 22 + server/src/routes/index.ts | 14 + server/src/routes/join-requests.ts | 33 + server/src/routes/proxy.ts | 49 ++ server/src/routes/servers.ts | 159 +++++ server/src/routes/users.ts | 46 ++ server/src/types/sqljs.d.ts | 3 + server/src/websocket/broadcast.ts | 37 ++ server/src/websocket/handler.ts | 164 +++++ server/src/websocket/index.ts | 49 ++ server/src/websocket/state.ts | 3 + server/src/websocket/types.ts | 9 + server/tsconfig.json | 2 + src/app/core/services/attachment.service.ts | 9 +- .../chat-messages.component.html | 4 + .../chat-messages/chat-messages.component.ts | 120 +++- .../voice-controls.component.ts | 5 +- .../messages/messages-incoming.handlers.ts | 28 +- src/app/store/messages/messages.helpers.ts | 35 +- src/app/store/users/users.effects.ts | 50 +- 51 files changed, 1552 insertions(+), 996 deletions(-) rename instructions.md => project-files/userstories-for-note-to-self.md (100%) create mode 100644 server/src/app.ts create mode 100644 server/src/cqrs/commands/handlers/createJoinRequest.ts create mode 100644 server/src/cqrs/commands/handlers/deleteServer.ts create mode 100644 server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts create mode 100644 server/src/cqrs/commands/handlers/registerUser.ts create mode 100644 server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts create mode 100644 server/src/cqrs/commands/handlers/upsertServer.ts create mode 100644 server/src/cqrs/commands/index.ts create mode 100644 server/src/cqrs/index.ts create mode 100644 server/src/cqrs/mappers.ts create mode 100644 server/src/cqrs/queries/handlers/getAllPublicServers.ts create mode 100644 server/src/cqrs/queries/handlers/getJoinRequestById.ts create mode 100644 server/src/cqrs/queries/handlers/getPendingRequestsForServer.ts create mode 100644 server/src/cqrs/queries/handlers/getServerById.ts create mode 100644 server/src/cqrs/queries/handlers/getUserById.ts create mode 100644 server/src/cqrs/queries/handlers/getUserByUsername.ts create mode 100644 server/src/cqrs/queries/index.ts create mode 100644 server/src/cqrs/types.ts delete mode 100644 server/src/db.ts create mode 100644 server/src/db/database.ts create mode 100644 server/src/db/index.ts create mode 100644 server/src/db/types.ts create mode 100644 server/src/entities/AuthUserEntity.ts create mode 100644 server/src/entities/JoinRequestEntity.ts create mode 100644 server/src/entities/ServerEntity.ts create mode 100644 server/src/entities/index.ts create mode 100644 server/src/migrations/1000000000000-InitialSchema.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/index.ts create mode 100644 server/src/routes/join-requests.ts create mode 100644 server/src/routes/proxy.ts create mode 100644 server/src/routes/servers.ts create mode 100644 server/src/routes/users.ts create mode 100644 server/src/websocket/broadcast.ts create mode 100644 server/src/websocket/handler.ts create mode 100644 server/src/websocket/index.ts create mode 100644 server/src/websocket/state.ts create mode 100644 server/src/websocket/types.ts diff --git a/README.md b/README.md index e5d8355..8c222be 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,10 @@ -# Client +# Basic readme for Tuju / Zoracord +Peer-to-peer discord alternative for free -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.4. - -## Development server - -To start a local development server, run: - -```bash -ng serve -``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. +### Run it +1. npm i +2. add .env to root containing `SSL=true` +3. npm run dev ## Code scaffolding diff --git a/instructions.md b/project-files/userstories-for-note-to-self.md similarity index 100% rename from instructions.md rename to project-files/userstories-for-note-to-self.md diff --git a/server/package.json b/server/package.json index 84e51db..24089e7 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,9 @@ "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.18.2", + "reflect-metadata": "^0.2.2", "sql.js": "^1.9.0", + "typeorm": "^0.3.28", "uuid": "^9.0.0", "ws": "^8.14.2" }, diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..24a508e --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,14 @@ +import express from 'express'; +import cors from 'cors'; +import { registerRoutes } from './routes'; + +export function createApp(): express.Express { + const app = express(); + + app.use(cors()); + app.use(express.json()); + + registerRoutes(app); + + return app; +} diff --git a/server/src/cqrs/commands/handlers/createJoinRequest.ts b/server/src/cqrs/commands/handlers/createJoinRequest.ts new file mode 100644 index 0000000..8282973 --- /dev/null +++ b/server/src/cqrs/commands/handlers/createJoinRequest.ts @@ -0,0 +1,19 @@ +import { DataSource } from 'typeorm'; +import { JoinRequestEntity } from '../../../entities'; +import { CreateJoinRequestCommand } from '../../types'; + +export async function handleCreateJoinRequest(command: CreateJoinRequestCommand, dataSource: DataSource): Promise { + const repo = dataSource.getRepository(JoinRequestEntity); + const { request } = command.payload; + const entity = repo.create({ + id: request.id, + serverId: request.serverId, + userId: request.userId, + userPublicKey: request.userPublicKey, + displayName: request.displayName, + status: request.status, + createdAt: request.createdAt + }); + + await repo.save(entity); +} diff --git a/server/src/cqrs/commands/handlers/deleteServer.ts b/server/src/cqrs/commands/handlers/deleteServer.ts new file mode 100644 index 0000000..f7af057 --- /dev/null +++ b/server/src/cqrs/commands/handlers/deleteServer.ts @@ -0,0 +1,10 @@ +import { DataSource } from 'typeorm'; +import { ServerEntity, JoinRequestEntity } from '../../../entities'; +import { DeleteServerCommand } from '../../types'; + +export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise { + const { serverId } = command.payload; + + await dataSource.getRepository(JoinRequestEntity).delete({ serverId }); + await dataSource.getRepository(ServerEntity).delete(serverId); +} diff --git a/server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts b/server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts new file mode 100644 index 0000000..fea0c37 --- /dev/null +++ b/server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts @@ -0,0 +1,10 @@ +import { DataSource, LessThan } from 'typeorm'; +import { JoinRequestEntity } from '../../../entities'; +import { DeleteStaleJoinRequestsCommand } from '../../types'; + +export async function handleDeleteStaleJoinRequests(command: DeleteStaleJoinRequestsCommand, dataSource: DataSource): Promise { + const repo = dataSource.getRepository(JoinRequestEntity); + const cutoff = Date.now() - command.payload.maxAgeMs; + + await repo.delete({ createdAt: LessThan(cutoff) }); +} diff --git a/server/src/cqrs/commands/handlers/registerUser.ts b/server/src/cqrs/commands/handlers/registerUser.ts new file mode 100644 index 0000000..b66fcc0 --- /dev/null +++ b/server/src/cqrs/commands/handlers/registerUser.ts @@ -0,0 +1,17 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; +import { RegisterUserCommand } from '../../types'; + +export async function handleRegisterUser(command: RegisterUserCommand, dataSource: DataSource): Promise { + const repo = dataSource.getRepository(AuthUserEntity); + const { user } = command.payload; + const entity = repo.create({ + id: user.id, + username: user.username, + passwordHash: user.passwordHash, + displayName: user.displayName, + createdAt: user.createdAt + }); + + await repo.save(entity); +} diff --git a/server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts b/server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts new file mode 100644 index 0000000..79bc1dc --- /dev/null +++ b/server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts @@ -0,0 +1,10 @@ +import { DataSource } from 'typeorm'; +import { JoinRequestEntity } from '../../../entities'; +import { UpdateJoinRequestStatusCommand } from '../../types'; + +export async function handleUpdateJoinRequestStatus(command: UpdateJoinRequestStatusCommand, dataSource: DataSource): Promise { + const repo = dataSource.getRepository(JoinRequestEntity); + const { requestId, status } = command.payload; + + await repo.update(requestId, { status }); +} diff --git a/server/src/cqrs/commands/handlers/upsertServer.ts b/server/src/cqrs/commands/handlers/upsertServer.ts new file mode 100644 index 0000000..a32d989 --- /dev/null +++ b/server/src/cqrs/commands/handlers/upsertServer.ts @@ -0,0 +1,23 @@ +import { DataSource } from 'typeorm'; +import { ServerEntity } from '../../../entities'; +import { UpsertServerCommand } from '../../types'; + +export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise { + const repo = dataSource.getRepository(ServerEntity); + const { server } = command.payload; + const entity = repo.create({ + id: server.id, + name: server.name, + description: server.description ?? null, + ownerId: server.ownerId, + ownerPublicKey: server.ownerPublicKey, + isPrivate: server.isPrivate ? 1 : 0, + maxUsers: server.maxUsers, + currentUsers: server.currentUsers, + tags: JSON.stringify(server.tags), + createdAt: server.createdAt, + lastSeen: server.lastSeen + }); + + await repo.save(entity); +} diff --git a/server/src/cqrs/commands/index.ts b/server/src/cqrs/commands/index.ts new file mode 100644 index 0000000..9989a98 --- /dev/null +++ b/server/src/cqrs/commands/index.ts @@ -0,0 +1,27 @@ +import { DataSource } from 'typeorm'; +import { + CommandType, + CommandTypeKey, + Command, + RegisterUserCommand, + UpsertServerCommand, + DeleteServerCommand, + CreateJoinRequestCommand, + UpdateJoinRequestStatusCommand, + DeleteStaleJoinRequestsCommand +} from '../types'; +import { handleRegisterUser } from './handlers/registerUser'; +import { handleUpsertServer } from './handlers/upsertServer'; +import { handleDeleteServer } from './handlers/deleteServer'; +import { handleCreateJoinRequest } from './handlers/createJoinRequest'; +import { handleUpdateJoinRequestStatus } from './handlers/updateJoinRequestStatus'; +import { handleDeleteStaleJoinRequests } from './handlers/deleteStaleJoinRequests'; + +export const buildCommandHandlers = (dataSource: DataSource): Record Promise> => ({ + [CommandType.RegisterUser]: (cmd) => handleRegisterUser(cmd as RegisterUserCommand, dataSource), + [CommandType.UpsertServer]: (cmd) => handleUpsertServer(cmd as UpsertServerCommand, dataSource), + [CommandType.DeleteServer]: (cmd) => handleDeleteServer(cmd as DeleteServerCommand, dataSource), + [CommandType.CreateJoinRequest]: (cmd) => handleCreateJoinRequest(cmd as CreateJoinRequestCommand, dataSource), + [CommandType.UpdateJoinRequestStatus]: (cmd) => handleUpdateJoinRequestStatus(cmd as UpdateJoinRequestStatusCommand, dataSource), + [CommandType.DeleteStaleJoinRequests]: (cmd) => handleDeleteStaleJoinRequests(cmd as DeleteStaleJoinRequestsCommand, dataSource) +}); diff --git a/server/src/cqrs/index.ts b/server/src/cqrs/index.ts new file mode 100644 index 0000000..63cb58e --- /dev/null +++ b/server/src/cqrs/index.ts @@ -0,0 +1,66 @@ +/** + * Thin service layer - binds every CQRS handler to `getDataSource()` so + * routes can call one-liner functions instead of manually constructing + * command/query objects and passing the DataSource every time. + */ + +import { getDataSource } from '../db'; +import { + CommandType, + QueryType, + AuthUserPayload, + ServerPayload, + JoinRequestPayload +} from './types'; +import { handleRegisterUser } from './commands/handlers/registerUser'; +import { handleUpsertServer } from './commands/handlers/upsertServer'; +import { handleDeleteServer } from './commands/handlers/deleteServer'; +import { handleCreateJoinRequest } from './commands/handlers/createJoinRequest'; +import { handleUpdateJoinRequestStatus } from './commands/handlers/updateJoinRequestStatus'; +import { handleDeleteStaleJoinRequests } from './commands/handlers/deleteStaleJoinRequests'; +import { handleGetUserByUsername } from './queries/handlers/getUserByUsername'; +import { handleGetUserById } from './queries/handlers/getUserById'; +import { handleGetAllPublicServers } from './queries/handlers/getAllPublicServers'; +import { handleGetServerById } from './queries/handlers/getServerById'; +import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'; +import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer'; + +// --------------- Commands --------------- + +export const registerUser = (user: AuthUserPayload) => + handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource()); + +export const upsertServer = (server: ServerPayload) => + handleUpsertServer({ type: CommandType.UpsertServer, payload: { server } }, getDataSource()); + +export const deleteServer = (serverId: string) => + handleDeleteServer({ type: CommandType.DeleteServer, payload: { serverId } }, getDataSource()); + +export const createJoinRequest = (request: JoinRequestPayload) => + handleCreateJoinRequest({ type: CommandType.CreateJoinRequest, payload: { request } }, getDataSource()); + +export const updateJoinRequestStatus = (requestId: string, status: JoinRequestPayload['status']) => + handleUpdateJoinRequestStatus({ type: CommandType.UpdateJoinRequestStatus, payload: { requestId, status } }, getDataSource()); + +export const deleteStaleJoinRequests = (maxAgeMs: number) => + handleDeleteStaleJoinRequests({ type: CommandType.DeleteStaleJoinRequests, payload: { maxAgeMs } }, getDataSource()); + +// --------------- Queries --------------- + +export const getUserByUsername = (username: string) => + handleGetUserByUsername({ type: QueryType.GetUserByUsername, payload: { username } }, getDataSource()); + +export const getUserById = (userId: string) => + handleGetUserById({ type: QueryType.GetUserById, payload: { userId } }, getDataSource()); + +export const getAllPublicServers = () => + handleGetAllPublicServers(getDataSource()); + +export const getServerById = (serverId: string) => + handleGetServerById({ type: QueryType.GetServerById, payload: { serverId } }, getDataSource()); + +export const getJoinRequestById = (requestId: string) => + handleGetJoinRequestById({ type: QueryType.GetJoinRequestById, payload: { requestId } }, getDataSource()); + +export const getPendingRequestsForServer = (serverId: string) => + handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource()); diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts new file mode 100644 index 0000000..6f8b31a --- /dev/null +++ b/server/src/cqrs/mappers.ts @@ -0,0 +1,46 @@ +import { AuthUserEntity } from '../entities/AuthUserEntity'; +import { ServerEntity } from '../entities/ServerEntity'; +import { JoinRequestEntity } from '../entities/JoinRequestEntity'; +import { + AuthUserPayload, + ServerPayload, + JoinRequestPayload +} from './types'; + +export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { + return { + id: row.id, + username: row.username, + passwordHash: row.passwordHash, + displayName: row.displayName, + createdAt: row.createdAt + }; +} + +export function rowToServer(row: ServerEntity): ServerPayload { + return { + id: row.id, + name: row.name, + description: row.description ?? undefined, + ownerId: row.ownerId, + ownerPublicKey: row.ownerPublicKey, + isPrivate: !!row.isPrivate, + maxUsers: row.maxUsers, + currentUsers: row.currentUsers, + tags: JSON.parse(row.tags || '[]'), + createdAt: row.createdAt, + lastSeen: row.lastSeen + }; +} + +export function rowToJoinRequest(row: JoinRequestEntity): JoinRequestPayload { + return { + id: row.id, + serverId: row.serverId, + userId: row.userId, + userPublicKey: row.userPublicKey, + displayName: row.displayName, + status: row.status as JoinRequestPayload['status'], + createdAt: row.createdAt + }; +} diff --git a/server/src/cqrs/queries/handlers/getAllPublicServers.ts b/server/src/cqrs/queries/handlers/getAllPublicServers.ts new file mode 100644 index 0000000..ea88ceb --- /dev/null +++ b/server/src/cqrs/queries/handlers/getAllPublicServers.ts @@ -0,0 +1,10 @@ +import { DataSource } from 'typeorm'; +import { ServerEntity } from '../../../entities'; +import { rowToServer } from '../../mappers'; + +export async function handleGetAllPublicServers(dataSource: DataSource) { + const repo = dataSource.getRepository(ServerEntity); + const rows = await repo.find({ where: { isPrivate: 0 } }); + + return rows.map(rowToServer); +} diff --git a/server/src/cqrs/queries/handlers/getJoinRequestById.ts b/server/src/cqrs/queries/handlers/getJoinRequestById.ts new file mode 100644 index 0000000..a1e6144 --- /dev/null +++ b/server/src/cqrs/queries/handlers/getJoinRequestById.ts @@ -0,0 +1,11 @@ +import { DataSource } from 'typeorm'; +import { JoinRequestEntity } from '../../../entities'; +import { GetJoinRequestByIdQuery } from '../../types'; +import { rowToJoinRequest } from '../../mappers'; + +export async function handleGetJoinRequestById(query: GetJoinRequestByIdQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(JoinRequestEntity); + const row = await repo.findOne({ where: { id: query.payload.requestId } }); + + return row ? rowToJoinRequest(row) : null; +} diff --git a/server/src/cqrs/queries/handlers/getPendingRequestsForServer.ts b/server/src/cqrs/queries/handlers/getPendingRequestsForServer.ts new file mode 100644 index 0000000..16090fe --- /dev/null +++ b/server/src/cqrs/queries/handlers/getPendingRequestsForServer.ts @@ -0,0 +1,11 @@ +import { DataSource } from 'typeorm'; +import { JoinRequestEntity } from '../../../entities'; +import { GetPendingRequestsForServerQuery } from '../../types'; +import { rowToJoinRequest } from '../../mappers'; + +export async function handleGetPendingRequestsForServer(query: GetPendingRequestsForServerQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(JoinRequestEntity); + const rows = await repo.find({ where: { serverId: query.payload.serverId, status: 'pending' } }); + + return rows.map(rowToJoinRequest); +} diff --git a/server/src/cqrs/queries/handlers/getServerById.ts b/server/src/cqrs/queries/handlers/getServerById.ts new file mode 100644 index 0000000..95eecaf --- /dev/null +++ b/server/src/cqrs/queries/handlers/getServerById.ts @@ -0,0 +1,11 @@ +import { DataSource } from 'typeorm'; +import { ServerEntity } from '../../../entities'; +import { GetServerByIdQuery } from '../../types'; +import { rowToServer } from '../../mappers'; + +export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(ServerEntity); + const row = await repo.findOne({ where: { id: query.payload.serverId } }); + + return row ? rowToServer(row) : null; +} diff --git a/server/src/cqrs/queries/handlers/getUserById.ts b/server/src/cqrs/queries/handlers/getUserById.ts new file mode 100644 index 0000000..edcb1f8 --- /dev/null +++ b/server/src/cqrs/queries/handlers/getUserById.ts @@ -0,0 +1,11 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; +import { GetUserByIdQuery } from '../../types'; +import { rowToAuthUser } from '../../mappers'; + +export async function handleGetUserById(query: GetUserByIdQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(AuthUserEntity); + const row = await repo.findOne({ where: { id: query.payload.userId } }); + + return row ? rowToAuthUser(row) : null; +} diff --git a/server/src/cqrs/queries/handlers/getUserByUsername.ts b/server/src/cqrs/queries/handlers/getUserByUsername.ts new file mode 100644 index 0000000..dc23b73 --- /dev/null +++ b/server/src/cqrs/queries/handlers/getUserByUsername.ts @@ -0,0 +1,11 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; +import { GetUserByUsernameQuery } from '../../types'; +import { rowToAuthUser } from '../../mappers'; + +export async function handleGetUserByUsername(query: GetUserByUsernameQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(AuthUserEntity); + const row = await repo.findOne({ where: { username: query.payload.username } }); + + return row ? rowToAuthUser(row) : null; +} diff --git a/server/src/cqrs/queries/index.ts b/server/src/cqrs/queries/index.ts new file mode 100644 index 0000000..66099bf --- /dev/null +++ b/server/src/cqrs/queries/index.ts @@ -0,0 +1,26 @@ +import { DataSource } from 'typeorm'; +import { + QueryType, + QueryTypeKey, + Query, + GetUserByUsernameQuery, + GetUserByIdQuery, + GetServerByIdQuery, + GetJoinRequestByIdQuery, + GetPendingRequestsForServerQuery +} from '../types'; +import { handleGetUserByUsername } from './handlers/getUserByUsername'; +import { handleGetUserById } from './handlers/getUserById'; +import { handleGetAllPublicServers } from './handlers/getAllPublicServers'; +import { handleGetServerById } from './handlers/getServerById'; +import { handleGetJoinRequestById } from './handlers/getJoinRequestById'; +import { handleGetPendingRequestsForServer } from './handlers/getPendingRequestsForServer'; + +export const buildQueryHandlers = (dataSource: DataSource): Record Promise> => ({ + [QueryType.GetUserByUsername]: (query) => handleGetUserByUsername(query as GetUserByUsernameQuery, dataSource), + [QueryType.GetUserById]: (query) => handleGetUserById(query as GetUserByIdQuery, dataSource), + [QueryType.GetAllPublicServers]: () => handleGetAllPublicServers(dataSource), + [QueryType.GetServerById]: (query) => handleGetServerById(query as GetServerByIdQuery, dataSource), + [QueryType.GetJoinRequestById]: (query) => handleGetJoinRequestById(query as GetJoinRequestByIdQuery, dataSource), + [QueryType.GetPendingRequestsForServer]: (query) => handleGetPendingRequestsForServer(query as GetPendingRequestsForServerQuery, dataSource) +}); diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts new file mode 100644 index 0000000..1e855a3 --- /dev/null +++ b/server/src/cqrs/types.ts @@ -0,0 +1,144 @@ +/* ------------------------------------------------------------------ */ +/* CQRS type definitions for the MetoYou server process. */ +/* Commands mutate state; queries read state. */ +/* ------------------------------------------------------------------ */ + +// --------------- Command types --------------- + +export const CommandType = { + RegisterUser: 'register-user', + UpsertServer: 'upsert-server', + DeleteServer: 'delete-server', + CreateJoinRequest: 'create-join-request', + UpdateJoinRequestStatus: 'update-join-request-status', + DeleteStaleJoinRequests: 'delete-stale-join-requests' +} as const; + +export type CommandTypeKey = typeof CommandType[keyof typeof CommandType]; + +// --------------- Query types --------------- + +export const QueryType = { + GetUserByUsername: 'get-user-by-username', + GetUserById: 'get-user-by-id', + GetAllPublicServers: 'get-all-public-servers', + GetServerById: 'get-server-by-id', + GetJoinRequestById: 'get-join-request-by-id', + GetPendingRequestsForServer: 'get-pending-requests-for-server' +} as const; + +export type QueryTypeKey = typeof QueryType[keyof typeof QueryType]; + +// --------------- Payload interfaces --------------- + +export interface AuthUserPayload { + id: string; + username: string; + passwordHash: string; + displayName: string; + createdAt: number; +} + +export interface ServerPayload { + id: string; + name: string; + description?: string; + ownerId: string; + ownerPublicKey: string; + isPrivate: boolean; + maxUsers: number; + currentUsers: number; + tags: string[]; + createdAt: number; + lastSeen: number; +} + +export interface JoinRequestPayload { + id: string; + serverId: string; + userId: string; + userPublicKey: string; + displayName: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: number; +} + +// --------------- Command interfaces --------------- + +export interface RegisterUserCommand { + type: typeof CommandType.RegisterUser; + payload: { user: AuthUserPayload }; +} + +export interface UpsertServerCommand { + type: typeof CommandType.UpsertServer; + payload: { server: ServerPayload }; +} + +export interface DeleteServerCommand { + type: typeof CommandType.DeleteServer; + payload: { serverId: string }; +} + +export interface CreateJoinRequestCommand { + type: typeof CommandType.CreateJoinRequest; + payload: { request: JoinRequestPayload }; +} + +export interface UpdateJoinRequestStatusCommand { + type: typeof CommandType.UpdateJoinRequestStatus; + payload: { requestId: string; status: JoinRequestPayload['status'] }; +} + +export interface DeleteStaleJoinRequestsCommand { + type: typeof CommandType.DeleteStaleJoinRequests; + payload: { maxAgeMs: number }; +} + +export type Command = + | RegisterUserCommand + | UpsertServerCommand + | DeleteServerCommand + | CreateJoinRequestCommand + | UpdateJoinRequestStatusCommand + | DeleteStaleJoinRequestsCommand; + +// --------------- Query interfaces --------------- + +export interface GetUserByUsernameQuery { + type: typeof QueryType.GetUserByUsername; + payload: { username: string }; +} + +export interface GetUserByIdQuery { + type: typeof QueryType.GetUserById; + payload: { userId: string }; +} + +export interface GetAllPublicServersQuery { + type: typeof QueryType.GetAllPublicServers; + payload: Record; +} + +export interface GetServerByIdQuery { + type: typeof QueryType.GetServerById; + payload: { serverId: string }; +} + +export interface GetJoinRequestByIdQuery { + type: typeof QueryType.GetJoinRequestById; + payload: { requestId: string }; +} + +export interface GetPendingRequestsForServerQuery { + type: typeof QueryType.GetPendingRequestsForServer; + payload: { serverId: string }; +} + +export type Query = + | GetUserByUsernameQuery + | GetUserByIdQuery + | GetAllPublicServersQuery + | GetServerByIdQuery + | GetJoinRequestByIdQuery + | GetPendingRequestsForServerQuery; diff --git a/server/src/db.ts b/server/src/db.ts deleted file mode 100644 index 654f871..0000000 --- a/server/src/db.ts +++ /dev/null @@ -1,367 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import initSqlJs from 'sql.js'; - -// Simple SQLite via sql.js persisted to a single file -const DATA_DIR = path.join(process.cwd(), 'data'); -const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); - -function ensureDataDir() { - if (!fs.existsSync(DATA_DIR)) - fs.mkdirSync(DATA_DIR, { recursive: true }); -} - -let SQL: any = null; -let db: any | null = null; - -export async function initDB(): Promise { - if (db) - return; - - SQL = await initSqlJs({ locateFile: (file: string) => require.resolve('sql.js/dist/sql-wasm.wasm') }); - ensureDataDir(); - - if (fs.existsSync(DB_FILE)) { - const fileBuffer = fs.readFileSync(DB_FILE); - - db = new SQL.Database(new Uint8Array(fileBuffer)); - } else { - db = new SQL.Database(); - } - - // Initialize schema - db.run(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - passwordHash TEXT NOT NULL, - displayName TEXT NOT NULL, - createdAt INTEGER NOT NULL - ); - `); - - db.run(` - CREATE TABLE IF NOT EXISTS servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - ownerId TEXT NOT NULL, - ownerPublicKey TEXT NOT NULL, - isPrivate INTEGER NOT NULL DEFAULT 0, - maxUsers INTEGER NOT NULL DEFAULT 0, - currentUsers INTEGER NOT NULL DEFAULT 0, - tags TEXT NOT NULL DEFAULT '[]', - createdAt INTEGER NOT NULL, - lastSeen INTEGER NOT NULL - ); - `); - - db.run(` - CREATE TABLE IF NOT EXISTS join_requests ( - id TEXT PRIMARY KEY, - serverId TEXT NOT NULL, - userId TEXT NOT NULL, - userPublicKey TEXT NOT NULL, - displayName TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - createdAt INTEGER NOT NULL - ); - `); - - persist(); -} - -function persist(): void { - if (!db) - return; - - const data = db.export(); - const buffer = Buffer.from(data); - - fs.writeFileSync(DB_FILE, buffer); -} - -/* ------------------------------------------------------------------ */ -/* Auth Users */ -/* ------------------------------------------------------------------ */ - -export interface AuthUser { - id: string; - username: string; - passwordHash: string; - displayName: string; - createdAt: number; -} - -export async function getUserByUsername(username: string): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1'); - - stmt.bind([username]); - let row: AuthUser | null = null; - - if (stmt.step()) { - const r = stmt.getAsObject() as any; - - row = { - id: String(r.id), - username: String(r.username), - passwordHash: String(r.passwordHash), - displayName: String(r.displayName), - createdAt: Number(r.createdAt) - }; - } - - stmt.free(); - return row; -} - -export async function getUserById(id: string): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1'); - - stmt.bind([id]); - let row: AuthUser | null = null; - - if (stmt.step()) { - const r = stmt.getAsObject() as any; - - row = { - id: String(r.id), - username: String(r.username), - passwordHash: String(r.passwordHash), - displayName: String(r.displayName), - createdAt: Number(r.createdAt) - }; - } - - stmt.free(); - return row; -} - -export async function createUser(user: AuthUser): Promise { - if (!db) - await initDB(); - - const stmt = db!.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)'); - - stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]); - stmt.step(); - stmt.free(); - persist(); -} - -/* ------------------------------------------------------------------ */ -/* Servers */ -/* ------------------------------------------------------------------ */ - -export interface ServerInfo { - id: string; - name: string; - description?: string; - ownerId: string; - ownerPublicKey: string; - isPrivate: boolean; - maxUsers: number; - currentUsers: number; - tags: string[]; - createdAt: number; - lastSeen: number; -} - -function rowToServer(r: any): ServerInfo { - return { - id: String(r.id), - name: String(r.name), - description: r.description ? String(r.description) : undefined, - ownerId: String(r.ownerId), - ownerPublicKey: String(r.ownerPublicKey), - isPrivate: !!r.isPrivate, - maxUsers: Number(r.maxUsers), - currentUsers: Number(r.currentUsers), - tags: JSON.parse(String(r.tags || '[]')), - createdAt: Number(r.createdAt), - lastSeen: Number(r.lastSeen) - }; -} - -export async function getAllPublicServers(): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT * FROM servers WHERE isPrivate = 0'); - const results: ServerInfo[] = []; - - while (stmt.step()) { - results.push(rowToServer(stmt.getAsObject())); - } - - stmt.free(); - return results; -} - -export async function getServerById(id: string): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1'); - - stmt.bind([id]); - let row: ServerInfo | null = null; - - if (stmt.step()) { - row = rowToServer(stmt.getAsObject()); - } - - stmt.free(); - return row; -} - -export async function upsertServer(server: ServerInfo): Promise { - if (!db) - await initDB(); - - const stmt = db!.prepare(` - INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - stmt.bind([ - server.id, - server.name, - server.description ?? null, - server.ownerId, - server.ownerPublicKey, - server.isPrivate ? 1 : 0, - server.maxUsers, - server.currentUsers, - JSON.stringify(server.tags), - server.createdAt, - server.lastSeen - ]); - stmt.step(); - stmt.free(); - persist(); -} - -export async function deleteServer(id: string): Promise { - if (!db) - await initDB(); - - const stmt = db!.prepare('DELETE FROM servers WHERE id = ?'); - - stmt.bind([id]); - stmt.step(); - stmt.free(); - // Also clean up related join requests - const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?'); - - jStmt.bind([id]); - jStmt.step(); - jStmt.free(); - persist(); -} - -/* ------------------------------------------------------------------ */ -/* Join Requests */ -/* ------------------------------------------------------------------ */ - -export interface JoinRequest { - id: string; - serverId: string; - userId: string; - userPublicKey: string; - displayName: string; - status: 'pending' | 'approved' | 'rejected'; - createdAt: number; -} - -function rowToJoinRequest(r: any): JoinRequest { - return { - id: String(r.id), - serverId: String(r.serverId), - userId: String(r.userId), - userPublicKey: String(r.userPublicKey), - displayName: String(r.displayName), - status: String(r.status) as JoinRequest['status'], - createdAt: Number(r.createdAt) - }; -} - -export async function createJoinRequest(req: JoinRequest): Promise { - if (!db) - await initDB(); - - const stmt = db!.prepare(` - INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]); - stmt.step(); - stmt.free(); - persist(); -} - -export async function getJoinRequestById(id: string): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1'); - - stmt.bind([id]); - let row: JoinRequest | null = null; - - if (stmt.step()) { - row = rowToJoinRequest(stmt.getAsObject()); - } - - stmt.free(); - return row; -} - -export async function getPendingRequestsForServer(serverId: string): Promise { - if (!db) - await initDB(); - - const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?'); - - stmt.bind([serverId, 'pending']); - const results: JoinRequest[] = []; - - while (stmt.step()) { - results.push(rowToJoinRequest(stmt.getAsObject())); - } - - stmt.free(); - return results; -} - -export async function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise { - if (!db) - await initDB(); - - const stmt = db!.prepare('UPDATE join_requests SET status = ? WHERE id = ?'); - - stmt.bind([status, id]); - stmt.step(); - stmt.free(); - persist(); -} - -export async function deleteStaleJoinRequests(maxAgeMs: number): Promise { - if (!db) - await initDB(); - - const cutoff = Date.now() - maxAgeMs; - const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?'); - - stmt.bind([cutoff]); - stmt.step(); - stmt.free(); - persist(); -} diff --git a/server/src/db/database.ts b/server/src/db/database.ts new file mode 100644 index 0000000..93ececd --- /dev/null +++ b/server/src/db/database.ts @@ -0,0 +1,59 @@ +import fs from 'fs'; +import path from 'path'; +import { DataSource } from 'typeorm'; +import { + AuthUserEntity, + ServerEntity, + JoinRequestEntity +} from '../entities'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); + +let applicationDataSource: DataSource | undefined; + +export function getDataSource(): DataSource { + if (!applicationDataSource?.isInitialized) { + throw new Error('DataSource not initialised'); + } + + return applicationDataSource; +} + +export async function initDatabase(): Promise { + if (!fs.existsSync(DATA_DIR)) + fs.mkdirSync(DATA_DIR, { recursive: true }); + + let database: Uint8Array | undefined; + + if (fs.existsSync(DB_FILE)) + database = fs.readFileSync(DB_FILE); + + applicationDataSource = new DataSource({ + type: 'sqljs', + database, + entities: [AuthUserEntity, ServerEntity, JoinRequestEntity], + migrations: [ + path.join(__dirname, '..', 'migrations', '*.js'), + path.join(__dirname, '..', 'migrations', '*.ts') + ], + synchronize: false, + logging: false, + autoSave: true, + location: DB_FILE + }); + + await applicationDataSource.initialize(); + console.log('[DB] Connection initialised at:', DB_FILE); + + await applicationDataSource.runMigrations(); + console.log('[DB] Migrations executed'); +} + +export async function destroyDatabase(): Promise { + if (applicationDataSource?.isInitialized) { + await applicationDataSource.destroy(); + applicationDataSource = undefined; + console.log('[DB] Connection closed'); + } +} diff --git a/server/src/db/index.ts b/server/src/db/index.ts new file mode 100644 index 0000000..89e9fae --- /dev/null +++ b/server/src/db/index.ts @@ -0,0 +1 @@ +export { initDatabase, destroyDatabase, getDataSource } from './database'; diff --git a/server/src/db/types.ts b/server/src/db/types.ts new file mode 100644 index 0000000..24a5ca5 --- /dev/null +++ b/server/src/db/types.ts @@ -0,0 +1,31 @@ +export interface AuthUser { + id: string; + username: string; + passwordHash: string; + displayName: string; + createdAt: number; +} + +export interface ServerInfo { + id: string; + name: string; + description?: string; + ownerId: string; + ownerPublicKey: string; + isPrivate: boolean; + maxUsers: number; + currentUsers: number; + tags: string[]; + createdAt: number; + lastSeen: number; +} + +export interface JoinRequest { + id: string; + serverId: string; + userId: string; + userPublicKey: string; + displayName: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: number; +} diff --git a/server/src/entities/AuthUserEntity.ts b/server/src/entities/AuthUserEntity.ts new file mode 100644 index 0000000..915fa8c --- /dev/null +++ b/server/src/entities/AuthUserEntity.ts @@ -0,0 +1,23 @@ +import { + Entity, + PrimaryColumn, + Column +} from 'typeorm'; + +@Entity('users') +export class AuthUserEntity { + @PrimaryColumn('text') + id!: string; + + @Column('text', { unique: true }) + username!: string; + + @Column('text') + passwordHash!: string; + + @Column('text') + displayName!: string; + + @Column('integer') + createdAt!: number; +} diff --git a/server/src/entities/JoinRequestEntity.ts b/server/src/entities/JoinRequestEntity.ts new file mode 100644 index 0000000..40cbb67 --- /dev/null +++ b/server/src/entities/JoinRequestEntity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryColumn, + Column, + Index +} from 'typeorm'; + +@Entity('join_requests') +export class JoinRequestEntity { + @PrimaryColumn('text') + id!: string; + + @Index() + @Column('text') + serverId!: string; + + @Column('text') + userId!: string; + + @Column('text') + userPublicKey!: string; + + @Column('text') + displayName!: string; + + @Column('text', { default: 'pending' }) + status!: string; + + @Column('integer') + createdAt!: number; +} diff --git a/server/src/entities/ServerEntity.ts b/server/src/entities/ServerEntity.ts new file mode 100644 index 0000000..59c47ba --- /dev/null +++ b/server/src/entities/ServerEntity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryColumn, + Column +} from 'typeorm'; + +@Entity('servers') +export class ServerEntity { + @PrimaryColumn('text') + id!: string; + + @Column('text') + name!: string; + + @Column('text', { nullable: true }) + description!: string | null; + + @Column('text') + ownerId!: string; + + @Column('text') + ownerPublicKey!: string; + + @Column('integer', { default: 0 }) + isPrivate!: number; + + @Column('integer', { default: 0 }) + maxUsers!: number; + + @Column('integer', { default: 0 }) + currentUsers!: number; + + @Column('text', { default: '[]' }) + tags!: string; + + @Column('integer') + createdAt!: number; + + @Column('integer') + lastSeen!: number; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts new file mode 100644 index 0000000..f264602 --- /dev/null +++ b/server/src/entities/index.ts @@ -0,0 +1,3 @@ +export { AuthUserEntity } from './AuthUserEntity'; +export { ServerEntity } from './ServerEntity'; +export { JoinRequestEntity } from './JoinRequestEntity'; diff --git a/server/src/index.ts b/server/src/index.ts index 4bc98c4..0ed4ebb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,356 +1,23 @@ +import 'reflect-metadata'; import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; +import { createServer as createHttpServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; // Load .env from project root (one level up from server/) dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); -import express from 'express'; -import cors from 'cors'; -import { createServer as createHttpServer } from 'http'; -import { createServer as createHttpsServer } from 'https'; -import { WebSocketServer, WebSocket } from 'ws'; -import { v4 as uuidv4 } from 'uuid'; +import { initDatabase } from './db'; +import { deleteStaleJoinRequests } from './cqrs'; +import { createApp } from './app'; +import { setupWebSocket } from './websocket'; const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true'; -const app = express(); const PORT = process.env.PORT || 3001; -app.use(cors()); -app.use(express.json()); - -// In-memory runtime state (WebSocket connections only - not persisted) -interface ConnectedUser { - oderId: string; - ws: WebSocket; - serverIds: Set; // all servers the user is a member of - viewedServerId?: string; // currently viewed/active server - displayName?: string; -} - -const connectedUsers = new Map(); - -// Database -import crypto from 'crypto'; -import { - initDB, - getUserByUsername, - createUser, - getAllPublicServers, - getServerById, - upsertServer, - deleteServer as dbDeleteServer, - createJoinRequest, - getJoinRequestById, - getPendingRequestsForServer, - updateJoinRequestStatus, - deleteStaleJoinRequests, - ServerInfo, - JoinRequest -} from './db'; - -function hashPassword(pw: string) { - return crypto.createHash('sha256') - .update(pw) - .digest('hex'); } - -// REST API Routes - -// Health check endpoint -app.get('/api/health', async (req, res) => { - const allServers = await getAllPublicServers(); - - res.json({ - status: 'ok', - timestamp: Date.now(), - serverCount: allServers.length, - connectedUsers: connectedUsers.size - }); -}); - -// Time endpoint for clock synchronization -app.get('/api/time', (req, res) => { - res.json({ now: Date.now() }); -}); - -// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:) -app.get('/api/image-proxy', async (req, res) => { - try { - const url = String(req.query.url || ''); - - if (!/^https?:\/\//i.test(url)) { - return res.status(400).json({ error: 'Invalid URL' }); - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); - - clearTimeout(timeout); - - if (!response.ok) { - return res.status(response.status).end(); - } - - const contentType = response.headers.get('content-type') || ''; - - if (!contentType.toLowerCase().startsWith('image/')) { - return res.status(415).json({ error: 'Unsupported content type' }); - } - - const arrayBuffer = await response.arrayBuffer(); - const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit - - if (arrayBuffer.byteLength > MAX_BYTES) { - return res.status(413).json({ error: 'Image too large' }); - } - - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=3600'); - res.send(Buffer.from(arrayBuffer)); - } catch (err) { - if ((err as any)?.name === 'AbortError') { - return res.status(504).json({ error: 'Timeout fetching image' }); - } - - console.error('Image proxy error:', err); - res.status(502).json({ error: 'Failed to fetch image' }); - } -}); - -// Auth -app.post('/api/users/register', async (req, res) => { - const { username, password, displayName } = req.body; - - if (!username || !password) - return res.status(400).json({ error: 'Missing username/password' }); - - const exists = await getUserByUsername(username); - - if (exists) - return res.status(409).json({ error: 'Username taken' }); - - const user = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; - - await createUser(user); - res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); -}); - -app.post('/api/users/login', async (req, res) => { - const { username, password } = req.body; - const user = await getUserByUsername(username); - - if (!user || user.passwordHash !== hashPassword(password)) - return res.status(401).json({ error: 'Invalid credentials' }); - - res.json({ id: user.id, username: user.username, displayName: user.displayName }); -}); - -// Search servers -app.get('/api/servers', async (req, res) => { - const { q, tags, limit = 20, offset = 0 } = req.query; - - let results = await getAllPublicServers(); - - results = results - .filter(s => { - if (q) { - const query = String(q).toLowerCase(); - - return s.name.toLowerCase().includes(query) || - s.description?.toLowerCase().includes(query); - } - - return true; - }) - .filter(s => { - if (tags) { - const tagList = String(tags).split(','); - - return tagList.some(t => s.tags.includes(t)); - } - - return true; - }); - - const total = results.length; - - results = results.slice(Number(offset), Number(offset) + Number(limit)); - - res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); -}); - -// Register a server -app.post('/api/servers', async (req, res) => { - const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; - - if (!name || !ownerId || !ownerPublicKey) { - return res.status(400).json({ error: 'Missing required fields' }); - } - - const id = clientId || uuidv4(); - const server: ServerInfo = { - id, - name, - description, - ownerId, - ownerPublicKey, - isPrivate: isPrivate ?? false, - maxUsers: maxUsers ?? 0, - currentUsers: 0, - tags: tags ?? [], - createdAt: Date.now(), - lastSeen: Date.now() - }; - - await upsertServer(server); - res.status(201).json(server); -}); - -// Update server -app.put('/api/servers/:id', async (req, res) => { - const { id } = req.params; - const { ownerId, ...updates } = req.body; - const server = await getServerById(id); - - if (!server) { - return res.status(404).json({ error: 'Server not found' }); - } - - if (server.ownerId !== ownerId) { - return res.status(403).json({ error: 'Not authorized' }); - } - - const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() }; - - await upsertServer(updated); - res.json(updated); -}); - -// Heartbeat - keep server alive -app.post('/api/servers/:id/heartbeat', async (req, res) => { - const { id } = req.params; - const { currentUsers } = req.body; - const server = await getServerById(id); - - if (!server) { - return res.status(404).json({ error: 'Server not found' }); - } - - server.lastSeen = Date.now(); - - if (typeof currentUsers === 'number') { - server.currentUsers = currentUsers; - } - - await upsertServer(server); - - res.json({ ok: true }); -}); - -// Remove server -app.delete('/api/servers/:id', async (req, res) => { - const { id } = req.params; - const { ownerId } = req.body; - const server = await getServerById(id); - - if (!server) { - return res.status(404).json({ error: 'Server not found' }); - } - - if (server.ownerId !== ownerId) { - return res.status(403).json({ error: 'Not authorized' }); - } - - await dbDeleteServer(id); - res.json({ ok: true }); -}); - -// Request to join a server -app.post('/api/servers/:id/join', async (req, res) => { - const { id: serverId } = req.params; - const { userId, userPublicKey, displayName } = req.body; - const server = await getServerById(serverId); - - if (!server) { - return res.status(404).json({ error: 'Server not found' }); - } - - const requestId = uuidv4(); - const request: JoinRequest = { - id: requestId, - serverId, - userId, - userPublicKey, - displayName, - status: server.isPrivate ? 'pending' : 'approved', - createdAt: Date.now() - }; - - await createJoinRequest(request); - - // Notify server owner via WebSocket - if (server.isPrivate) { - notifyServerOwner(server.ownerId, { - type: 'join_request', - request - }); - } - - res.status(201).json(request); -}); - -// Get join requests for a server -app.get('/api/servers/:id/requests', async (req, res) => { - const { id: serverId } = req.params; - const { ownerId } = req.query; - const server = await getServerById(serverId); - - if (!server) { - return res.status(404).json({ error: 'Server not found' }); - } - - if (server.ownerId !== ownerId) { - return res.status(403).json({ error: 'Not authorized' }); - } - - const requests = await getPendingRequestsForServer(serverId); - - res.json({ requests }); -}); - -// Approve/reject join request -app.put('/api/requests/:id', async (req, res) => { - const { id } = req.params; - const { ownerId, status } = req.body; - const request = await getJoinRequestById(id); - - if (!request) { - return res.status(404).json({ error: 'Request not found' }); - } - - const server = await getServerById(request.serverId); - - if (!server || server.ownerId !== ownerId) { - return res.status(403).json({ error: 'Not authorized' }); - } - - await updateJoinRequestStatus(id, status); - const updated = { ...request, status }; - - // Notify the requester - notifyUser(request.userId, { - type: 'request_update', - request: updated - }); - - res.json(updated); -}); - -// WebSocket Server for real-time signaling -function buildServer() { +function buildServer(app: ReturnType) { if (USE_SSL) { - // Look for certs relative to project root (one level up from server/) const certDir = path.resolve(__dirname, '..', '..', '.certs'); const certFile = path.join(certDir, 'localhost.crt'); const keyFile = path.join(certDir, 'localhost.key'); @@ -370,249 +37,31 @@ function buildServer() { return createHttpServer(app); } -const server = buildServer(); -const wss = new WebSocketServer({ server }); +async function bootstrap(): Promise { + await initDatabase(); -wss.on('connection', (ws: WebSocket) => { - const connectionId = uuidv4(); + const app = createApp(); + const server = buildServer(app); - connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); + setupWebSocket(server); - ws.on('message', (data) => { - try { - const message = JSON.parse(data.toString()); + // Periodically clean up stale join requests (older than 24 h) + setInterval(() => { + deleteStaleJoinRequests(24 * 60 * 60 * 1000) + .catch(err => console.error('Failed to clean up stale join requests:', err)); + }, 60 * 1000); - handleWebSocketMessage(connectionId, message); - } catch (err) { - console.error('Invalid WebSocket message:', err); - } - }); - - ws.on('close', () => { - const user = connectedUsers.get(connectionId); - - if (user) { - // Notify all servers the user was a member of - user.serverIds.forEach((sid) => { - broadcastToServer(sid, { - type: 'user_left', - oderId: user.oderId, - displayName: user.displayName, - serverId: sid - }, user.oderId); - }); - } - - connectedUsers.delete(connectionId); - }); - - // Send connection acknowledgment with the connectionId (client will identify with their actual oderId) - ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() })); -}); - -function handleWebSocketMessage(connectionId: string, message: any): void { - const user = connectedUsers.get(connectionId); - - if (!user) - return; - - switch (message.type) { - case 'identify': - // User identifies themselves with their permanent ID - // Store their actual oderId for peer-to-peer routing - user.oderId = message.oderId || connectionId; - user.displayName = message.displayName || 'Anonymous'; - connectedUsers.set(connectionId, user); - console.log(`User identified: ${user.displayName} (${user.oderId})`); - break; - - case 'join_server': { - const sid = message.serverId; - const isNew = !user.serverIds.has(sid); - - user.serverIds.add(sid); - user.viewedServerId = sid; - connectedUsers.set(connectionId, user); - console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); - - // Always send the current user list for this server - const usersInServer = Array.from(connectedUsers.values()) - .filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName) - .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); - - console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer); - user.ws.send(JSON.stringify({ - type: 'server_users', - serverId: sid, - users: usersInServer - })); - - // Only broadcast user_joined if this is a brand-new join (not a re-view) - if (isNew) { - broadcastToServer(sid, { - type: 'user_joined', - oderId: user.oderId, - displayName: user.displayName || 'Anonymous', - serverId: sid - }, user.oderId); - } - - break; - } - - case 'view_server': { - // Just switch the viewed server without joining/leaving - const viewSid = message.serverId; - - user.viewedServerId = viewSid; - connectedUsers.set(connectionId, user); - console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); - - // Send current user list for the viewed server - const viewUsers = Array.from(connectedUsers.values()) - .filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName) - .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); - - user.ws.send(JSON.stringify({ - type: 'server_users', - serverId: viewSid, - users: viewUsers - })); - - break; - } - - case 'leave_server': { - const leaveSid = message.serverId || user.viewedServerId; - - if (leaveSid) { - user.serverIds.delete(leaveSid); - - if (user.viewedServerId === leaveSid) { - user.viewedServerId = undefined; - } - - connectedUsers.set(connectionId, user); - - broadcastToServer(leaveSid, { - type: 'user_left', - oderId: user.oderId, - displayName: user.displayName || 'Anonymous', - serverId: leaveSid - }, user.oderId); - } - - break; - } - - case 'offer': - case 'answer': - case 'ice_candidate': - // Forward signaling messages to specific peer - console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`); - const targetUser = findUserByUserId(message.targetUserId); - - if (targetUser) { - targetUser.ws.send(JSON.stringify({ - ...message, - fromUserId: user.oderId - })); - - console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`); - } else { - console.log(`Target user ${message.targetUserId} not found. Connected users:`, - Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName }))); - } - - break; - - case 'chat_message': { - // Broadcast chat message to all users in the server - const chatSid = message.serverId || user.viewedServerId; - - if (chatSid && user.serverIds.has(chatSid)) { - broadcastToServer(chatSid, { - type: 'chat_message', - serverId: chatSid, - message: message.message, - senderId: user.oderId, - senderName: user.displayName, - timestamp: Date.now() - }); - } - - break; - } - - case 'typing': { - // Broadcast typing indicator - const typingSid = message.serverId || user.viewedServerId; - - if (typingSid && user.serverIds.has(typingSid)) { - broadcastToServer(typingSid, { - type: 'user_typing', - serverId: typingSid, - oderId: user.oderId, - displayName: user.displayName - }, user.oderId); - } - - break; - } - - default: - console.log('Unknown message type:', message.type); - } -} - -function broadcastToServer(serverId: string, message: any, excludeOderId?: string): void { - console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); - connectedUsers.forEach((user) => { - if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { - console.log(` -> Sending to ${user.displayName} (${user.oderId})`); - user.ws.send(JSON.stringify(message)); - } - }); -} - -function notifyServerOwner(ownerId: string, message: any): void { - const owner = findUserByUserId(ownerId); - - if (owner) { - owner.ws.send(JSON.stringify(message)); - } -} - -function notifyUser(oderId: string, message: any): void { - const user = findUserByUserId(oderId); - - if (user) { - user.ws.send(JSON.stringify(message)); - } -} - -function findUserByUserId(oderId: string): ConnectedUser | undefined { - return Array.from(connectedUsers.values()).find(u => u.oderId === oderId); -} - -// Cleanup stale join requests periodically (older than 24 h) -setInterval(() => { - deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err => - console.error('Failed to clean up stale join requests:', err) - ); -}, 60 * 1000); - -initDB().then(() => { server.listen(PORT, () => { const proto = USE_SSL ? 'https' : 'http'; const wsProto = USE_SSL ? 'wss' : 'ws'; - console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); + console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); console.log(` REST API: ${proto}://localhost:${PORT}/api`); console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); }); -}) - .catch((err) => { - console.error('Failed to initialize database:', err); - process.exit(1); - }); +} + +bootstrap().catch((err) => { + console.error('Failed to start server:', err); + process.exit(1); +}); diff --git a/server/src/migrations/1000000000000-InitialSchema.ts b/server/src/migrations/1000000000000-InitialSchema.ts new file mode 100644 index 0000000..ef1d9c1 --- /dev/null +++ b/server/src/migrations/1000000000000-InitialSchema.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialSchema1000000000000 implements MigrationInterface { + name = 'InitialSchema1000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "users" ( + "id" TEXT PRIMARY KEY NOT NULL, + "username" TEXT UNIQUE NOT NULL, + "passwordHash" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "servers" ( + "id" TEXT PRIMARY KEY NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ownerId" TEXT NOT NULL, + "ownerPublicKey" TEXT NOT NULL, + "isPrivate" INTEGER NOT NULL DEFAULT 0, + "maxUsers" INTEGER NOT NULL DEFAULT 0, + "currentUsers" INTEGER NOT NULL DEFAULT 0, + "tags" TEXT NOT NULL DEFAULT '[]', + "createdAt" INTEGER NOT NULL, + "lastSeen" INTEGER NOT NULL + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "join_requests" ( + "id" TEXT PRIMARY KEY NOT NULL, + "serverId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "userPublicKey" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" INTEGER NOT NULL + ) + `); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_join_requests_serverId" ON "join_requests" ("serverId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "join_requests"`); + await queryRunner.query(`DROP TABLE IF EXISTS "servers"`); + await queryRunner.query(`DROP TABLE IF EXISTS "users"`); + } +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..6dcf7ac --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { getAllPublicServers } from '../cqrs'; +import { connectedUsers } from '../websocket/state'; + +const router = Router(); + +router.get('/health', async (_req, res) => { + const servers = await getAllPublicServers(); + + res.json({ + status: 'ok', + timestamp: Date.now(), + serverCount: servers.length, + connectedUsers: connectedUsers.size + }); +}); + +router.get('/time', (_req, res) => { + res.json({ now: Date.now() }); +}); + +export default router; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts new file mode 100644 index 0000000..48e8f69 --- /dev/null +++ b/server/src/routes/index.ts @@ -0,0 +1,14 @@ +import { Express } from 'express'; +import healthRouter from './health'; +import proxyRouter from './proxy'; +import usersRouter from './users'; +import serversRouter from './servers'; +import joinRequestsRouter from './join-requests'; + +export function registerRoutes(app: Express): void { + app.use('/api', healthRouter); + app.use('/api', proxyRouter); + app.use('/api/users', usersRouter); + app.use('/api/servers', serversRouter); + app.use('/api/requests', joinRequestsRouter); +} diff --git a/server/src/routes/join-requests.ts b/server/src/routes/join-requests.ts new file mode 100644 index 0000000..cd262f2 --- /dev/null +++ b/server/src/routes/join-requests.ts @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { JoinRequestPayload } from '../cqrs/types'; +import { + getJoinRequestById, + getServerById, + updateJoinRequestStatus +} from '../cqrs'; +import { notifyUser } from '../websocket/broadcast'; + +const router = Router(); + +router.put('/:id', async (req, res) => { + const { id } = req.params; + const { ownerId, status } = req.body; + const request = await getJoinRequestById(id); + + if (!request) + return res.status(404).json({ error: 'Request not found' }); + + const server = await getServerById(request.serverId); + + if (!server || server.ownerId !== ownerId) + return res.status(403).json({ error: 'Not authorized' }); + + await updateJoinRequestStatus(id, status as JoinRequestPayload['status']); + + const updated: JoinRequestPayload = { ...request, status }; + + notifyUser(request.userId, { type: 'request_update', request: updated }); + res.json(updated); +}); + +export default router; diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts new file mode 100644 index 0000000..bc71921 --- /dev/null +++ b/server/src/routes/proxy.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; + +const router = Router(); + +router.get('/image-proxy', async (req, res) => { + try { + const url = String(req.query.url || ''); + + if (!/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: 'Invalid URL' }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); + + clearTimeout(timeout); + + if (!response.ok) { + return res.status(response.status).end(); + } + + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.toLowerCase().startsWith('image/')) { + return res.status(415).json({ error: 'Unsupported content type' }); + } + + const arrayBuffer = await response.arrayBuffer(); + const MAX_BYTES = 8 * 1024 * 1024; + + if (arrayBuffer.byteLength > MAX_BYTES) { + return res.status(413).json({ error: 'Image too large' }); + } + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(Buffer.from(arrayBuffer)); + } catch (err) { + if ((err as { name?: string })?.name === 'AbortError') { + return res.status(504).json({ error: 'Timeout fetching image' }); + } + + console.error('Image proxy error:', err); + res.status(502).json({ error: 'Failed to fetch image' }); + } +}); + +export default router; diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts new file mode 100644 index 0000000..19ceed5 --- /dev/null +++ b/server/src/routes/servers.ts @@ -0,0 +1,159 @@ +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { ServerPayload, JoinRequestPayload } from '../cqrs/types'; +import { + getAllPublicServers, + getServerById, + upsertServer, + deleteServer, + createJoinRequest, + getPendingRequestsForServer +} from '../cqrs'; +import { notifyServerOwner } from '../websocket/broadcast'; + +const router = Router(); + +router.get('/', async (req, res) => { + const { q, tags, limit = 20, offset = 0 } = req.query; + + let results = await getAllPublicServers(); + + if (q) { + const search = String(q).toLowerCase(); + + results = results.filter(server => + server.name.toLowerCase().includes(search) || + server.description?.toLowerCase().includes(search) + ); + } + + if (tags) { + const tagList = String(tags).split(','); + + results = results.filter(server => tagList.some(tag => server.tags.includes(tag))); + } + + const total = results.length; + + results = results.slice(Number(offset), Number(offset) + Number(limit)); + + res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); +}); + +router.post('/', async (req, res) => { + const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; + + if (!name || !ownerId || !ownerPublicKey) + return res.status(400).json({ error: 'Missing required fields' }); + + const server: ServerPayload = { + id: clientId || uuidv4(), + name, + description, + ownerId, + ownerPublicKey, + isPrivate: isPrivate ?? false, + maxUsers: maxUsers ?? 0, + currentUsers: 0, + tags: tags ?? [], + createdAt: Date.now(), + lastSeen: Date.now() + }; + + await upsertServer(server); + res.status(201).json(server); +}); + +router.put('/:id', async (req, res) => { + const { id } = req.params; + const { ownerId, ...updates } = req.body; + const existing = await getServerById(id); + + if (!existing) + return res.status(404).json({ error: 'Server not found' }); + + if (existing.ownerId !== ownerId) + return res.status(403).json({ error: 'Not authorized' }); + + const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() }; + + await upsertServer(server); + res.json(server); +}); + +router.post('/:id/heartbeat', async (req, res) => { + const { id } = req.params; + const { currentUsers } = req.body; + const existing = await getServerById(id); + + if (!existing) + return res.status(404).json({ error: 'Server not found' }); + + const server: ServerPayload = { + ...existing, + lastSeen: Date.now(), + currentUsers: typeof currentUsers === 'number' ? currentUsers : existing.currentUsers + }; + + await upsertServer(server); + res.json({ ok: true }); +}); + +router.delete('/:id', async (req, res) => { + const { id } = req.params; + const { ownerId } = req.body; + const existing = await getServerById(id); + + if (!existing) + return res.status(404).json({ error: 'Server not found' }); + + if (existing.ownerId !== ownerId) + return res.status(403).json({ error: 'Not authorized' }); + + await deleteServer(id); + res.json({ ok: true }); +}); + +router.post('/:id/join', async (req, res) => { + const { id: serverId } = req.params; + const { userId, userPublicKey, displayName } = req.body; + const server = await getServerById(serverId); + + if (!server) + return res.status(404).json({ error: 'Server not found' }); + + const request: JoinRequestPayload = { + id: uuidv4(), + serverId, + userId, + userPublicKey, + displayName, + status: server.isPrivate ? 'pending' : 'approved', + createdAt: Date.now() + }; + + await createJoinRequest(request); + + if (server.isPrivate) + notifyServerOwner(server.ownerId, { type: 'join_request', request }); + + res.status(201).json(request); +}); + +router.get('/:id/requests', async (req, res) => { + const { id: serverId } = req.params; + const { ownerId } = req.query; + const server = await getServerById(serverId); + + if (!server) + return res.status(404).json({ error: 'Server not found' }); + + if (server.ownerId !== ownerId) + return res.status(403).json({ error: 'Not authorized' }); + + const requests = await getPendingRequestsForServer(serverId); + + res.json({ requests }); +}); + +export default router; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 0000000..5b96f8e --- /dev/null +++ b/server/src/routes/users.ts @@ -0,0 +1,46 @@ +import crypto from 'crypto'; +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { getUserByUsername, registerUser } from '../cqrs'; + +const router = Router(); + +function hashPassword(pw: string): string { + return crypto.createHash('sha256').update(pw) + .digest('hex'); +} + +router.post('/register', async (req, res) => { + const { username, password, displayName } = req.body; + + if (!username || !password) + return res.status(400).json({ error: 'Missing username/password' }); + + const existing = await getUserByUsername(username); + + if (existing) + return res.status(409).json({ error: 'Username taken' }); + + const user = { + id: uuidv4(), + username, + passwordHash: hashPassword(password), + displayName: displayName || username, + createdAt: Date.now() + }; + + await registerUser(user); + res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); +}); + +router.post('/login', async (req, res) => { + const { username, password } = req.body; + const user = await getUserByUsername(username); + + if (!user || user.passwordHash !== hashPassword(password)) + return res.status(401).json({ error: 'Invalid credentials' }); + + res.json({ id: user.id, username: user.username, displayName: user.displayName }); +}); + +export default router; diff --git a/server/src/types/sqljs.d.ts b/server/src/types/sqljs.d.ts index d7d825e..177d7ab 100644 --- a/server/src/types/sqljs.d.ts +++ b/server/src/types/sqljs.d.ts @@ -1,6 +1,9 @@ declare module 'sql.js'; declare module 'sql.js' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Database = any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Statement = any; } diff --git a/server/src/websocket/broadcast.ts b/server/src/websocket/broadcast.ts new file mode 100644 index 0000000..b4e3add --- /dev/null +++ b/server/src/websocket/broadcast.ts @@ -0,0 +1,37 @@ +import { connectedUsers } from './state'; + +interface WsMessage { + [key: string]: unknown; + type: string; +} + +export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void { + console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); + + connectedUsers.forEach((user) => { + if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { + console.log(` -> Sending to ${user.displayName} (${user.oderId})`); + user.ws.send(JSON.stringify(message)); + } + }); +} + +export function notifyServerOwner(ownerId: string, message: WsMessage): void { + const owner = findUserByOderId(ownerId); + + if (owner) { + owner.ws.send(JSON.stringify(message)); + } +} + +export function notifyUser(oderId: string, message: WsMessage): void { + const user = findUserByOderId(oderId); + + if (user) { + user.ws.send(JSON.stringify(message)); + } +} + +export function findUserByOderId(oderId: string) { + return Array.from(connectedUsers.values()).find(user => user.oderId === oderId); +} diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts new file mode 100644 index 0000000..557eb2f --- /dev/null +++ b/server/src/websocket/handler.ts @@ -0,0 +1,164 @@ +import { connectedUsers } from './state'; +import { ConnectedUser } from './types'; +import { broadcastToServer, findUserByOderId } from './broadcast'; + +interface WsMessage { + [key: string]: unknown; + type: string; +} + +/** Sends the current user list for a given server to a single connected user. */ +function sendServerUsers(user: ConnectedUser, serverId: string): void { + const users = Array.from(connectedUsers.values()) + .filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName) + .map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' })); + + user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); +} + +function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { + user.oderId = String(message['oderId'] || connectionId); + user.displayName = String(message['displayName'] || 'Anonymous'); + connectedUsers.set(connectionId, user); + console.log(`User identified: ${user.displayName} (${user.oderId})`); +} + +function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { + const sid = String(message['serverId']); + const isNew = !user.serverIds.has(sid); + + user.serverIds.add(sid); + user.viewedServerId = sid; + connectedUsers.set(connectionId, user); + console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); + + sendServerUsers(user, sid); + + if (isNew) { + broadcastToServer(sid, { + type: 'user_joined', + oderId: user.oderId, + displayName: user.displayName ?? 'Anonymous', + serverId: sid + }, user.oderId); + } +} + +function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { + const viewSid = String(message['serverId']); + + user.viewedServerId = viewSid; + connectedUsers.set(connectionId, user); + console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); + + sendServerUsers(user, viewSid); +} + +function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { + const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; + + if (!leaveSid) + return; + + user.serverIds.delete(leaveSid); + + if (user.viewedServerId === leaveSid) + user.viewedServerId = undefined; + + connectedUsers.set(connectionId, user); + + broadcastToServer(leaveSid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName ?? 'Anonymous', + serverId: leaveSid + }, user.oderId); +} + +function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { + const targetUserId = String(message['targetUserId'] || ''); + + console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); + + const targetUser = findUserByOderId(targetUserId); + + if (targetUser) { + targetUser.ws.send(JSON.stringify({ ...message, fromUserId: user.oderId })); + console.log(`Successfully forwarded ${message.type} to ${targetUserId}`); + } else { + console.log( + `Target user ${targetUserId} not found. Connected users:`, + Array.from(connectedUsers.values()).map(cu => ({ oderId: cu.oderId, displayName: cu.displayName })) + ); + } +} + +function handleChatMessage(user: ConnectedUser, message: WsMessage): void { + const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; + + if (chatSid && user.serverIds.has(chatSid)) { + broadcastToServer(chatSid, { + type: 'chat_message', + serverId: chatSid, + message: message['message'], + senderId: user.oderId, + senderName: user.displayName, + timestamp: Date.now() + }); + } +} + +function handleTyping(user: ConnectedUser, message: WsMessage): void { + const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; + + if (typingSid && user.serverIds.has(typingSid)) { + broadcastToServer(typingSid, { + type: 'user_typing', + serverId: typingSid, + oderId: user.oderId, + displayName: user.displayName + }, user.oderId); + } +} + +export function handleWebSocketMessage(connectionId: string, message: WsMessage): void { + const user = connectedUsers.get(connectionId); + + if (!user) + return; + + switch (message.type) { + case 'identify': + handleIdentify(user, message, connectionId); + break; + + case 'join_server': + handleJoinServer(user, message, connectionId); + break; + + case 'view_server': + handleViewServer(user, message, connectionId); + break; + + case 'leave_server': + handleLeaveServer(user, message, connectionId); + break; + + case 'offer': + case 'answer': + case 'ice_candidate': + forwardRtcMessage(user, message); + break; + + case 'chat_message': + handleChatMessage(user, message); + break; + + case 'typing': + handleTyping(user, message); + break; + + default: + console.log('Unknown message type:', message.type); + } +} diff --git a/server/src/websocket/index.ts b/server/src/websocket/index.ts new file mode 100644 index 0000000..ceb6581 --- /dev/null +++ b/server/src/websocket/index.ts @@ -0,0 +1,49 @@ +import { + IncomingMessage, + Server, + ServerResponse +} from 'http'; +import { WebSocketServer, WebSocket } from 'ws'; +import { v4 as uuidv4 } from 'uuid'; +import { connectedUsers } from './state'; +import { broadcastToServer } from './broadcast'; +import { handleWebSocketMessage } from './handler'; + +export function setupWebSocket(server: Server): void { + const wss = new WebSocketServer({ server }); + + wss.on('connection', (ws: WebSocket) => { + const connectionId = uuidv4(); + + connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + + handleWebSocketMessage(connectionId, message); + } catch (err) { + console.error('Invalid WebSocket message:', err); + } + }); + + ws.on('close', () => { + const user = connectedUsers.get(connectionId); + + if (user) { + user.serverIds.forEach((sid) => { + broadcastToServer(sid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName, + serverId: sid + }, user.oderId); + }); + } + + connectedUsers.delete(connectionId); + }); + + ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() })); + }); +} diff --git a/server/src/websocket/state.ts b/server/src/websocket/state.ts new file mode 100644 index 0000000..c42ed22 --- /dev/null +++ b/server/src/websocket/state.ts @@ -0,0 +1,3 @@ +import { ConnectedUser } from './types'; + +export const connectedUsers = new Map(); diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts new file mode 100644 index 0000000..97639d9 --- /dev/null +++ b/server/src/websocket/types.ts @@ -0,0 +1,9 @@ +import { WebSocket } from 'ws'; + +export interface ConnectedUser { + oderId: string; + ws: WebSocket; + serverIds: Set; + viewedServerId?: string; + displayName?: string; +} diff --git a/server/tsconfig.json b/server/tsconfig.json index 50572da..10b7bac 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,6 +3,8 @@ "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "outDir": "./dist", "rootDir": "./src", "strict": true, diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index 1631db6..aae5934 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -5,6 +5,7 @@ import { signal, effect } from '@angular/core'; +import { take } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { WebRTCService } from './webrtc.service'; import { Store } from '@ngrx/store'; @@ -983,12 +984,10 @@ export class AttachmentService { /** Resolve the display name of the current room via the NgRx store. */ private resolveCurrentRoomName(): Promise { return new Promise((resolve) => { - const subscription = this.ngrxStore + this.ngrxStore .select(selectCurrentRoomName) - .subscribe((name) => { - resolve(name || ''); - subscription.unsubscribe(); - }); + .pipe(take(1)) + .subscribe((name) => resolve(name || '')); }); } diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/src/app/features/chat/chat-messages/chat-messages.component.html index 47e91d8..472353e 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.html +++ b/src/app/features/chat/chat-messages/chat-messages.component.html @@ -516,6 +516,10 @@ class="chat-input-wrapper relative" (mouseenter)="inputHovered.set(true)" (mouseleave)="inputHovered.set(false)" + (dragenter)="onDragEnter($event)" + (dragover)="onDragOver($event)" + (dragleave)="onDragLeave($event)" + (drop)="onDrop($event)" >