Refactor 4 with bugfixes

This commit is contained in:
2026-03-04 03:56:23 +01:00
parent be91b6dfe8
commit 0ed9ca93d3
51 changed files with 1552 additions and 996 deletions

View File

@@ -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

View File

@@ -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
View 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;
}

View 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);
}

View 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);
}

View 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) });
}

View 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);
}

View 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 });
}

View 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);
}

View 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
View 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());

View 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
};
}

View 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);
}

View 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;
}

View File

@@ -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);
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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;

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
export { initDatabase, destroyDatabase, getDataSource } from './database';

31
server/src/db/types.ts Normal file
View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export { AuthUserEntity } from './AuthUserEntity';
export { ServerEntity } from './ServerEntity';
export { JoinRequestEntity } from './JoinRequestEntity';

View File

@@ -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());
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)
// 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)
);
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);
}
bootstrap().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});

View 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"`);
}
}

View 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;

View 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);
}

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;
}

View 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);
}

View 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);
}
}

View 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() }));
});
}

View File

@@ -0,0 +1,3 @@
import { ConnectedUser } from './types';
export const connectedUsers = new Map<string, ConnectedUser>();

View File

@@ -0,0 +1,9 @@
import { WebSocket } from 'ws';
export interface ConnectedUser {
oderId: string;
ws: WebSocket;
serverIds: Set<string>;
viewedServerId?: string;
displayName?: string;
}

View File

@@ -3,6 +3,8 @@
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,

View File

@@ -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 || ''));
});
}

View File

@@ -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

View File

@@ -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();
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. */

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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(