Refactor 4 with bugfixes
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
14
server/src/app.ts
Normal file
14
server/src/app.ts
Normal file
@@ -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;
|
||||
}
|
||||
19
server/src/cqrs/commands/handlers/createJoinRequest.ts
Normal file
19
server/src/cqrs/commands/handlers/createJoinRequest.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
10
server/src/cqrs/commands/handlers/deleteServer.ts
Normal file
10
server/src/cqrs/commands/handlers/deleteServer.ts
Normal file
@@ -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<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
}
|
||||
10
server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts
Normal file
10
server/src/cqrs/commands/handlers/deleteStaleJoinRequests.ts
Normal file
@@ -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<void> {
|
||||
const repo = dataSource.getRepository(JoinRequestEntity);
|
||||
const cutoff = Date.now() - command.payload.maxAgeMs;
|
||||
|
||||
await repo.delete({ createdAt: LessThan(cutoff) });
|
||||
}
|
||||
17
server/src/cqrs/commands/handlers/registerUser.ts
Normal file
17
server/src/cqrs/commands/handlers/registerUser.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
10
server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts
Normal file
10
server/src/cqrs/commands/handlers/updateJoinRequestStatus.ts
Normal file
@@ -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<void> {
|
||||
const repo = dataSource.getRepository(JoinRequestEntity);
|
||||
const { requestId, status } = command.payload;
|
||||
|
||||
await repo.update(requestId, { status });
|
||||
}
|
||||
23
server/src/cqrs/commands/handlers/upsertServer.ts
Normal file
23
server/src/cqrs/commands/handlers/upsertServer.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
27
server/src/cqrs/commands/index.ts
Normal file
27
server/src/cqrs/commands/index.ts
Normal file
@@ -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<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
|
||||
[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)
|
||||
});
|
||||
66
server/src/cqrs/index.ts
Normal file
66
server/src/cqrs/index.ts
Normal file
@@ -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());
|
||||
46
server/src/cqrs/mappers.ts
Normal file
46
server/src/cqrs/mappers.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
10
server/src/cqrs/queries/handlers/getAllPublicServers.ts
Normal file
10
server/src/cqrs/queries/handlers/getAllPublicServers.ts
Normal file
@@ -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);
|
||||
}
|
||||
11
server/src/cqrs/queries/handlers/getJoinRequestById.ts
Normal file
11
server/src/cqrs/queries/handlers/getJoinRequestById.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
11
server/src/cqrs/queries/handlers/getServerById.ts
Normal file
11
server/src/cqrs/queries/handlers/getServerById.ts
Normal file
@@ -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;
|
||||
}
|
||||
11
server/src/cqrs/queries/handlers/getUserById.ts
Normal file
11
server/src/cqrs/queries/handlers/getUserById.ts
Normal file
@@ -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;
|
||||
}
|
||||
11
server/src/cqrs/queries/handlers/getUserByUsername.ts
Normal file
11
server/src/cqrs/queries/handlers/getUserByUsername.ts
Normal file
@@ -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;
|
||||
}
|
||||
26
server/src/cqrs/queries/index.ts
Normal file
26
server/src/cqrs/queries/index.ts
Normal file
@@ -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<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||
[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)
|
||||
});
|
||||
144
server/src/cqrs/types.ts
Normal file
144
server/src/cqrs/types.ts
Normal file
@@ -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<string, never>;
|
||||
}
|
||||
|
||||
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;
|
||||
367
server/src/db.ts
367
server/src/db.ts
@@ -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<void> {
|
||||
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<AuthUser | null> {
|
||||
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<AuthUser | null> {
|
||||
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<void> {
|
||||
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<ServerInfo[]> {
|
||||
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<ServerInfo | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<JoinRequest | null> {
|
||||
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<JoinRequest[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
59
server/src/db/database.ts
Normal file
59
server/src/db/database.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
if (applicationDataSource?.isInitialized) {
|
||||
await applicationDataSource.destroy();
|
||||
applicationDataSource = undefined;
|
||||
console.log('[DB] Connection closed');
|
||||
}
|
||||
}
|
||||
1
server/src/db/index.ts
Normal file
1
server/src/db/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { initDatabase, destroyDatabase, getDataSource } from './database';
|
||||
31
server/src/db/types.ts
Normal file
31
server/src/db/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
23
server/src/entities/AuthUserEntity.ts
Normal file
23
server/src/entities/AuthUserEntity.ts
Normal file
@@ -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;
|
||||
}
|
||||
31
server/src/entities/JoinRequestEntity.ts
Normal file
31
server/src/entities/JoinRequestEntity.ts
Normal file
@@ -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;
|
||||
}
|
||||
41
server/src/entities/ServerEntity.ts
Normal file
41
server/src/entities/ServerEntity.ts
Normal file
@@ -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;
|
||||
}
|
||||
3
server/src/entities/index.ts
Normal file
3
server/src/entities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
@@ -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<string>; // all servers the user is a member of
|
||||
viewedServerId?: string; // currently viewed/active server
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const connectedUsers = new Map<string, ConnectedUser>();
|
||||
|
||||
// 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<typeof createApp>) {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
53
server/src/migrations/1000000000000-InitialSchema.ts
Normal file
53
server/src/migrations/1000000000000-InitialSchema.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitialSchema1000000000000 implements MigrationInterface {
|
||||
name = 'InitialSchema1000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
22
server/src/routes/health.ts
Normal file
22
server/src/routes/health.ts
Normal file
@@ -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;
|
||||
14
server/src/routes/index.ts
Normal file
14
server/src/routes/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
33
server/src/routes/join-requests.ts
Normal file
33
server/src/routes/join-requests.ts
Normal file
@@ -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;
|
||||
49
server/src/routes/proxy.ts
Normal file
49
server/src/routes/proxy.ts
Normal file
@@ -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;
|
||||
159
server/src/routes/servers.ts
Normal file
159
server/src/routes/servers.ts
Normal file
@@ -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;
|
||||
46
server/src/routes/users.ts
Normal file
46
server/src/routes/users.ts
Normal file
@@ -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;
|
||||
3
server/src/types/sqljs.d.ts
vendored
3
server/src/types/sqljs.d.ts
vendored
@@ -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<any>;
|
||||
// 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;
|
||||
}
|
||||
|
||||
37
server/src/websocket/broadcast.ts
Normal file
37
server/src/websocket/broadcast.ts
Normal file
@@ -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);
|
||||
}
|
||||
164
server/src/websocket/handler.ts
Normal file
164
server/src/websocket/handler.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
49
server/src/websocket/index.ts
Normal file
49
server/src/websocket/index.ts
Normal file
@@ -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<typeof IncomingMessage, typeof ServerResponse>): 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() }));
|
||||
});
|
||||
}
|
||||
3
server/src/websocket/state.ts
Normal file
3
server/src/websocket/state.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ConnectedUser } from './types';
|
||||
|
||||
export const connectedUsers = new Map<string, ConnectedUser>();
|
||||
9
server/src/websocket/types.ts
Normal file
9
server/src/websocket/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
export interface ConnectedUser {
|
||||
oderId: string;
|
||||
ws: WebSocket;
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
|
||||
@@ -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<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const subscription = this.ngrxStore
|
||||
this.ngrxStore
|
||||
.select(selectCurrentRoomName)
|
||||
.subscribe((name) => {
|
||||
resolve(name || '');
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
.pipe(take(1))
|
||||
.subscribe((name) => resolve(name || ''));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<textarea
|
||||
#messageInputRef
|
||||
|
||||
@@ -187,6 +187,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private toolbarHovering = false;
|
||||
inlineCodeToken = '`';
|
||||
dragActive = signal(false);
|
||||
private dragDepth = 0;
|
||||
inputHovered = signal(false);
|
||||
ctrlHeld = signal(false);
|
||||
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
@@ -766,49 +767,140 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
/** Handle drag-enter to activate the drop zone overlay. */
|
||||
// Attachments: drag/drop and rendering
|
||||
onDragEnter(evt: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(evt))
|
||||
return;
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.dragDepth++;
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
/** Keep the drop zone active while dragging over. */
|
||||
onDragOver(evt: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(evt))
|
||||
return;
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (evt.dataTransfer) {
|
||||
evt.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
/** Deactivate the drop zone when dragging leaves. */
|
||||
onDragLeave(evt: DragEvent): void {
|
||||
if (!this.dragActive())
|
||||
return;
|
||||
|
||||
evt.preventDefault();
|
||||
this.dragActive.set(false);
|
||||
evt.stopPropagation();
|
||||
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
||||
|
||||
if (this.dragDepth === 0) {
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle dropped files, adding them to the pending upload queue. */
|
||||
onDrop(evt: DragEvent): void {
|
||||
evt.preventDefault();
|
||||
const files: File[] = [];
|
||||
const items = evt.dataTransfer?.items;
|
||||
evt.stopPropagation();
|
||||
this.dragDepth = 0;
|
||||
const droppedFiles = this.extractDroppedFiles(evt);
|
||||
|
||||
if (droppedFiles.length === 0) {
|
||||
this.dragActive.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingFiles.push(...droppedFiles);
|
||||
// Keep toolbar visible so user sees options
|
||||
this.toolbarVisible.set(true);
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
|
||||
private hasPotentialFilePayload(evt: DragEvent): boolean {
|
||||
const dataTransfer = evt.dataTransfer;
|
||||
|
||||
if (!dataTransfer)
|
||||
return false;
|
||||
|
||||
if (dataTransfer.files?.length)
|
||||
return true;
|
||||
|
||||
const items = dataTransfer.items;
|
||||
|
||||
if (items?.length) {
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
if (items[itemIndex].kind === 'file') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const types = evt.dataTransfer?.types;
|
||||
|
||||
if (!types || types.length === 0)
|
||||
// Some desktop-to-browser drags expose no types until drop.
|
||||
return true;
|
||||
|
||||
for (let typeIndex = 0; typeIndex < types.length; typeIndex++) {
|
||||
const type = types[typeIndex];
|
||||
|
||||
if (
|
||||
type === 'Files' ||
|
||||
type === 'application/x-moz-file' ||
|
||||
type === 'public.file-url' ||
|
||||
type === 'text/uri-list'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractDroppedFiles(evt: DragEvent): File[] {
|
||||
const droppedFiles: File[] = [];
|
||||
const items = evt.dataTransfer?.items ?? null;
|
||||
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
const item = items[itemIndex];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file)
|
||||
files.push(file);
|
||||
droppedFiles.push(file);
|
||||
}
|
||||
}
|
||||
} else if (evt.dataTransfer?.files?.length) {
|
||||
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
|
||||
files.push(evt.dataTransfer.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => this.pendingFiles.push(file));
|
||||
// Keep toolbar visible so user sees options
|
||||
this.toolbarVisible.set(true);
|
||||
this.dragActive.set(false);
|
||||
const files = evt.dataTransfer?.files;
|
||||
|
||||
if (!files?.length)
|
||||
return droppedFiles;
|
||||
|
||||
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||
const file = files[fileIndex];
|
||||
const exists = droppedFiles.some((existing) =>
|
||||
existing.name === file.name &&
|
||||
existing.size === file.size &&
|
||||
existing.type === file.type &&
|
||||
existing.lastModified === file.lastModified
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
droppedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return droppedFiles;
|
||||
}
|
||||
|
||||
/** Return all file attachments associated with a message. */
|
||||
|
||||
@@ -205,7 +205,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Track local mic for voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
// Use oderId||id to match the key used by the rooms-side-panel template.
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
@@ -270,7 +271,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
|
||||
@@ -55,7 +55,7 @@ type MessageHandler = (
|
||||
*/
|
||||
function handleInventoryRequest(
|
||||
event: any,
|
||||
{ db, webrtc }: IncomingMessageContext
|
||||
{ db, webrtc, attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const { roomId, fromPeerId } = event;
|
||||
|
||||
@@ -66,7 +66,15 @@ function handleInventoryRequest(
|
||||
(async () => {
|
||||
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
|
||||
const items = await Promise.all(
|
||||
messages.map((msg) => buildInventoryItem(msg, db))
|
||||
messages.map((msg) => {
|
||||
const inMemoryAttachmentCount = attachments.getForMessage(msg.id).length;
|
||||
|
||||
return buildInventoryItem(
|
||||
msg,
|
||||
db,
|
||||
inMemoryAttachmentCount > 0 ? inMemoryAttachmentCount : undefined
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
|
||||
@@ -90,7 +98,7 @@ function handleInventoryRequest(
|
||||
*/
|
||||
function handleInventory(
|
||||
event: any,
|
||||
{ db, webrtc }: IncomingMessageContext
|
||||
{ db, webrtc, attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const { roomId, fromPeerId, items } = event;
|
||||
|
||||
@@ -100,7 +108,17 @@ function handleInventory(
|
||||
return from(
|
||||
(async () => {
|
||||
const local = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
|
||||
const localMap = await buildLocalInventoryMap(local, db);
|
||||
const inMemoryAttachmentCounts = new Map<string, number>();
|
||||
|
||||
for (const message of local) {
|
||||
const count = attachments.getForMessage(message.id).length;
|
||||
|
||||
if (count > 0) {
|
||||
inMemoryAttachmentCounts.set(message.id, count);
|
||||
}
|
||||
}
|
||||
|
||||
const localMap = await buildLocalInventoryMap(local, db, inMemoryAttachmentCounts);
|
||||
const missing = findMissingIds(items, localMap);
|
||||
|
||||
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
|
||||
@@ -204,7 +222,7 @@ async function processSyncBatch(
|
||||
toUpsert.push(message);
|
||||
}
|
||||
|
||||
if (event.attachments && event.fromPeerId) {
|
||||
if (event.attachments && typeof event.attachments === 'object') {
|
||||
requestMissingImages(event.attachments, attachments);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,33 +73,47 @@ export interface InventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
}
|
||||
|
||||
/** Builds a sync inventory item from a message and its reaction count. */
|
||||
export async function buildInventoryItem(
|
||||
msg: Message,
|
||||
db: DatabaseService
|
||||
db: DatabaseService,
|
||||
attachmentCountOverride?: number
|
||||
): Promise<InventoryItem> {
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
const attachments =
|
||||
attachmentCountOverride === undefined
|
||||
? await db.getAttachmentsForMessage(msg.id)
|
||||
: [];
|
||||
|
||||
return { id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: reactions.length };
|
||||
rc: reactions.length,
|
||||
ac: attachmentCountOverride ?? attachments.length };
|
||||
}
|
||||
|
||||
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
|
||||
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */
|
||||
export async function buildLocalInventoryMap(
|
||||
messages: Message[],
|
||||
db: DatabaseService
|
||||
): Promise<Map<string, { ts: number; rc: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number }>();
|
||||
db: DatabaseService,
|
||||
attachmentCountOverrides?: ReadonlyMap<string, number>
|
||||
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
||||
|
||||
await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
|
||||
const attachments =
|
||||
attachmentCountOverride === undefined
|
||||
? await db.getAttachmentsForMessage(msg.id)
|
||||
: [];
|
||||
|
||||
map.set(msg.id, { ts: getMessageTimestamp(msg),
|
||||
rc: reactions.length });
|
||||
rc: reactions.length,
|
||||
ac: attachmentCountOverride ?? attachments.length });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -108,8 +122,8 @@ export async function buildLocalInventoryMap(
|
||||
|
||||
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||
export function findMissingIds(
|
||||
remoteItems: readonly { id: string; ts: number; rc?: number }[],
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number }>
|
||||
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -119,7 +133,8 @@ export function findMissingIds(
|
||||
if (
|
||||
!local ||
|
||||
item.ts > local.ts ||
|
||||
(item.rc !== undefined && item.rc !== local.rc)
|
||||
(item.rc !== undefined && item.rc !== local.rc) ||
|
||||
(item.ac !== undefined && item.ac !== local.ac)
|
||||
) {
|
||||
missing.push(item.id);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { BanEntry } from '../../core/models';
|
||||
import {
|
||||
BanEntry,
|
||||
User
|
||||
} from '../../core/models';
|
||||
|
||||
@Injectable()
|
||||
export class UsersEffects {
|
||||
@@ -48,12 +51,22 @@ export class UsersEffects {
|
||||
ofType(UsersActions.loadCurrentUser),
|
||||
switchMap(() =>
|
||||
from(this.db.getCurrentUser()).pipe(
|
||||
map((user) => {
|
||||
if (user) {
|
||||
return UsersActions.loadCurrentUserSuccess({ user });
|
||||
switchMap((user) => {
|
||||
if (!user) {
|
||||
return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' }));
|
||||
}
|
||||
|
||||
return UsersActions.loadCurrentUserFailure({ error: 'No current user' });
|
||||
const sanitizedUser = this.clearStartupVoiceConnection(user);
|
||||
|
||||
if (sanitizedUser === user) {
|
||||
return of(UsersActions.loadCurrentUserSuccess({ user }));
|
||||
}
|
||||
|
||||
return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe(
|
||||
map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })),
|
||||
// If persistence fails, still load a sanitized in-memory user to keep UI correct.
|
||||
catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })))
|
||||
);
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
|
||||
@@ -63,6 +76,33 @@ export class UsersEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private clearStartupVoiceConnection(user: User): User {
|
||||
const voiceState = user.voiceState;
|
||||
|
||||
if (!voiceState)
|
||||
return user;
|
||||
|
||||
const hasStaleConnectionState =
|
||||
voiceState.isConnected ||
|
||||
voiceState.isSpeaking ||
|
||||
voiceState.roomId !== undefined ||
|
||||
voiceState.serverId !== undefined;
|
||||
|
||||
if (!hasStaleConnectionState)
|
||||
return user;
|
||||
|
||||
return {
|
||||
...user,
|
||||
voiceState: {
|
||||
...voiceState,
|
||||
isConnected: false,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads all users associated with a specific room from the local database. */
|
||||
loadRoomUsers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
|
||||
Reference in New Issue
Block a user