Private servers with password and invite links (Experimental)
This commit is contained in:
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', true);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
||||
import {
|
||||
ServerEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} 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(ServerMembershipEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
|
||||
@@ -24,6 +24,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
description: row.description ?? undefined,
|
||||
ownerId: row.ownerId,
|
||||
ownerPublicKey: row.ownerPublicKey,
|
||||
hasPassword: !!row.passwordHash,
|
||||
passwordHash: row.passwordHash ?? undefined,
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ServerPayload {
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
hasPassword?: boolean;
|
||||
passwordHash?: string | null;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: false,
|
||||
|
||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_bans')
|
||||
export class ServerBanEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('text')
|
||||
bannedBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
displayName!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
reason!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
expiresAt!: number | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
||||
@Column('text')
|
||||
ownerPublicKey!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
passwordHash!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isPrivate!: number;
|
||||
|
||||
|
||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_invites')
|
||||
export class ServerInviteEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Column('text')
|
||||
createdBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
createdByDisplayName!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Index()
|
||||
@Column('integer')
|
||||
expiresAt!: number;
|
||||
}
|
||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_memberships')
|
||||
export class ServerMembershipEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastAccessAt!: number;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
|
||||
@@ -97,12 +97,17 @@ async function bootstrap(): Promise<void> {
|
||||
const onListening = () => {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||
const localHostNames = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1'
|
||||
];
|
||||
|
||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||
|
||||
if (serverProtocol === 'https' && serverHost && !['localhost', '127.0.0.1', '::1'].includes(serverHost)) {
|
||||
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||
}
|
||||
};
|
||||
|
||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
||||
name = 'ServerAccessControl1000000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastAccessAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_invites" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdByDisplayName" TEXT,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"expiresAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_bans" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"bannedBy" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"reason" TEXT,
|
||||
"expiresAt" INTEGER,
|
||||
"createdAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
|
||||
export const serverMigrations = [InitialSchema1000000000000];
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
import joinRequestsRouter from './join-requests';
|
||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
|
||||
export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
app.use('/api/invites', invitesApiRouter);
|
||||
app.use('/api/requests', joinRequestsRouter);
|
||||
app.use('/invite', invitePageRouter);
|
||||
}
|
||||
|
||||
47
server/src/routes/invite-utils.ts
Normal file
47
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
function buildOrigin(protocol: string, host: string): string {
|
||||
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function getRequestOrigin(request: Request): string {
|
||||
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||
|
||||
return buildOrigin(protocol, host);
|
||||
}
|
||||
|
||||
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||
const url = new URL(signalOrigin);
|
||||
const host = url.host;
|
||||
|
||||
if (host === 'signal.toju.app') {
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
if (host.startsWith('signal.')) {
|
||||
return buildOrigin(url.protocol.replace(':', ''), host.replace(/^signal\./, 'web.'));
|
||||
}
|
||||
|
||||
if (['localhost:3001', '127.0.0.1:3001'].includes(host)) {
|
||||
return buildOrigin(url.protocol.replace(':', ''), host.replace(/:3001$/, ':4200'));
|
||||
}
|
||||
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||
}
|
||||
|
||||
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||
|
||||
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
|
||||
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import { getActiveServerInvite } from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
buildBrowserInviteUrl,
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
|
||||
export const invitesApiRouter = Router();
|
||||
export const invitePageRouter = Router();
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
...publicServer,
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
};
|
||||
}
|
||||
|
||||
function renderInvitePage(options: {
|
||||
appUrl?: string;
|
||||
browserUrl?: string;
|
||||
error?: string;
|
||||
expiresAt?: number;
|
||||
inviteUrl?: string;
|
||||
isExpired: boolean;
|
||||
ownerName?: string;
|
||||
serverDescription?: string;
|
||||
serverName: string;
|
||||
}) {
|
||||
const expiryLabel = options.expiresAt
|
||||
? new Date(options.expiresAt).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
: null;
|
||||
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
||||
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
||||
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
||||
const errorBlock = options.error
|
||||
? `<div class="notice notice-error">${options.error}</div>`
|
||||
: '';
|
||||
const description = options.serverDescription
|
||||
? `<p class="description">${options.serverDescription}</p>`
|
||||
: '<p class="description">You have been invited to join a Toju server.</p>';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Invite to ${options.serverName}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050816;
|
||||
--bg-soft: rgba(11, 18, 42, 0.78);
|
||||
--card: rgba(15, 23, 42, 0.92);
|
||||
--border: rgba(148, 163, 184, 0.18);
|
||||
--text: #f8fafc;
|
||||
--muted: #cbd5e1;
|
||||
--primary: #8b5cf6;
|
||||
--primary-soft: rgba(139, 92, 246, 0.16);
|
||||
--secondary: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
||||
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.shell {
|
||||
width: min(100%, 760px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 28px;
|
||||
background: var(--bg-soft);
|
||||
backdrop-filter: blur(22px);
|
||||
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero {
|
||||
padding: 36px 36px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--secondary);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: ${statusColor};
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 44rem;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px 36px 36px;
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.meta-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--card);
|
||||
padding: 18px;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.meta-value {
|
||||
margin-top: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-primary {
|
||||
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
||||
}
|
||||
.button-secondary {
|
||||
border-color: var(--border);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
.notice {
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notice-error {
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
background: rgba(127, 29, 29, 0.18);
|
||||
color: #fecaca;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 18px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #c4b5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hero, .content { padding-inline: 22px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
||||
<h1>Join ${options.serverName}</h1>
|
||||
${description}
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
${errorBlock}
|
||||
<div class="meta-grid">
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Server</div>
|
||||
<div class="meta-value">${options.serverName}</div>
|
||||
</article>
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Owner</div>
|
||||
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
||||
</article>
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Expires</div>
|
||||
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="${buttonOpacity}">
|
||||
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
||||
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
Invite links bypass private and password restrictions, but banned users still cannot join.
|
||||
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
||||
<a href="https://toju.app/downloads">Download Toju</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
invitesApiRouter.get('/:id', async (req, res) => {
|
||||
const signalOrigin = getRequestOrigin(req);
|
||||
const bundle = await getActiveServerInvite(req.params['id']);
|
||||
|
||||
if (!bundle) {
|
||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
|
||||
res.json({
|
||||
id: bundle.invite.id,
|
||||
serverId: bundle.invite.serverId,
|
||||
createdAt: bundle.invite.createdAt,
|
||||
expiresAt: bundle.invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||
sourceUrl: signalOrigin,
|
||||
createdBy: bundle.invite.createdBy,
|
||||
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
||||
isExpired: bundle.invite.expiresAt <= Date.now(),
|
||||
server: await enrichServer(server, signalOrigin)
|
||||
});
|
||||
});
|
||||
|
||||
invitePageRouter.get('/:id', async (req, res) => {
|
||||
const signalOrigin = getRequestOrigin(req);
|
||||
const bundle = await getActiveServerInvite(req.params['id']);
|
||||
|
||||
if (!bundle) {
|
||||
res.status(404).send(renderInvitePage({
|
||||
error: 'This invite has expired or is no longer available.',
|
||||
isExpired: true,
|
||||
serverName: 'Toju server'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const owner = await getUserById(server.ownerId);
|
||||
|
||||
res.send(renderInvitePage({
|
||||
serverName: server.name,
|
||||
serverDescription: server.description,
|
||||
ownerName: owner?.displayName,
|
||||
expiresAt: bundle.invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||
isExpired: bundle.invite.expiresAt <= Date.now()
|
||||
}));
|
||||
});
|
||||
@@ -1,29 +1,89 @@
|
||||
import { Router } from 'express';
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
getUserById,
|
||||
upsertServer,
|
||||
deleteServer,
|
||||
createJoinRequest,
|
||||
getPendingRequestsForServer
|
||||
} from '../cqrs';
|
||||
import { notifyServerOwner } from '../websocket/broadcast';
|
||||
import {
|
||||
banServerUser,
|
||||
buildSignalingUrl,
|
||||
createServerInvite,
|
||||
joinServerWithAccess,
|
||||
passwordHashForInput,
|
||||
ServerAccessError,
|
||||
kickServerUser,
|
||||
ensureServerMembership,
|
||||
unbanServerUser
|
||||
} from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
buildBrowserInviteUrl,
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function enrichServer(server: ServerPayload) {
|
||||
function normalizeRole(role: unknown): string | null {
|
||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||
return !!role && allowedRoles.includes(role);
|
||||
}
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
...server,
|
||||
...publicServer,
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
};
|
||||
}
|
||||
|
||||
function sendAccessError(error: unknown, res: Response) {
|
||||
if (error instanceof ServerAccessError) {
|
||||
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled server access error:', error);
|
||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
async function buildInviteResponse(invite: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string | null;
|
||||
serverId: string;
|
||||
}, server: ServerPayload, signalOrigin: string) {
|
||||
return {
|
||||
id: invite.id,
|
||||
serverId: invite.serverId,
|
||||
createdAt: invite.createdAt,
|
||||
expiresAt: invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
||||
sourceUrl: signalOrigin,
|
||||
createdBy: invite.createdBy,
|
||||
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
||||
isExpired: invite.expiresAt <= Date.now(),
|
||||
server: await enrichServer(server, signalOrigin)
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
@@ -54,17 +114,30 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
isPrivate,
|
||||
maxUsers,
|
||||
password,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
|
||||
const passwordHash = passwordHashForInput(password);
|
||||
const server: ServerPayload = {
|
||||
id: clientId || uuidv4(),
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
hasPassword: !!passwordHash,
|
||||
passwordHash,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
@@ -74,25 +147,198 @@ router.post('/', async (req, res) => {
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
res.status(201).json(server);
|
||||
await ensureServerMembership(server.id, ownerId);
|
||||
|
||||
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentOwnerId, ...updates } = req.body;
|
||||
const {
|
||||
currentOwnerId,
|
||||
actingRole,
|
||||
password,
|
||||
hasPassword: _ignoredHasPassword,
|
||||
passwordHash: _ignoredPasswordHash,
|
||||
...updates
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
const normalizedRole = normalizeRole(actingRole);
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (existing.ownerId !== authenticatedOwnerId)
|
||||
if (
|
||||
existing.ownerId !== authenticatedOwnerId &&
|
||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||
const server: ServerPayload = {
|
||||
...existing,
|
||||
...updates,
|
||||
hasPassword: !!nextPasswordHash,
|
||||
passwordHash: nextPasswordHash,
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
res.json(server);
|
||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.post('/:id/join', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, password, inviteId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await joinServerWithAccess({
|
||||
serverId,
|
||||
userId: String(userId),
|
||||
password: typeof password === 'string' ? password : undefined,
|
||||
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||
});
|
||||
const origin = getRequestOrigin(req);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
signalingUrl: buildSignalingUrl(origin),
|
||||
joinedBefore: result.joinedBefore,
|
||||
via: result.via,
|
||||
server: await enrichServer(result.server, origin)
|
||||
});
|
||||
} catch (error) {
|
||||
sendAccessError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invites', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { requesterUserId, requesterDisplayName } = req.body;
|
||||
|
||||
if (!requesterUserId) {
|
||||
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||
}
|
||||
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
try {
|
||||
const invite = await createServerInvite(
|
||||
serverId,
|
||||
String(requesterUserId),
|
||||
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||
);
|
||||
|
||||
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
||||
} catch (error) {
|
||||
sendAccessError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/kick', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!targetUserId) {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
await kickServerUser(serverId, String(targetUserId));
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/ban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!targetUserId) {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
await banServerUser({
|
||||
serverId,
|
||||
userId: String(targetUserId),
|
||||
banId: typeof banId === 'string' ? banId : undefined,
|
||||
bannedBy: String(actorUserId || ''),
|
||||
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||
reason: typeof reason === 'string' ? reason : undefined,
|
||||
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/unban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
await unbanServerUser({
|
||||
serverId,
|
||||
banId: typeof banId === 'string' ? banId : undefined,
|
||||
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/heartbeat', async (req, res) => {
|
||||
@@ -128,32 +374,6 @@ router.delete('/:id', async (req, res) => {
|
||||
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;
|
||||
@@ -170,4 +390,15 @@ router.get('/:id/requests', async (req, res) => {
|
||||
res.json({ requests });
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const server = await getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
365
server/src/services/server-access.service.ts
Normal file
365
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDataSource } from '../db/database';
|
||||
import {
|
||||
ServerBanEntity,
|
||||
ServerEntity,
|
||||
ServerInviteEntity,
|
||||
ServerMembershipEntity
|
||||
} from '../entities';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
|
||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
||||
|
||||
export interface JoinServerAccessResult {
|
||||
joinedBefore: boolean;
|
||||
server: ServerPayload;
|
||||
via: JoinAccessVia;
|
||||
}
|
||||
|
||||
export interface BanServerUserOptions {
|
||||
banId?: string;
|
||||
bannedBy: string;
|
||||
displayName?: string;
|
||||
expiresAt?: number;
|
||||
reason?: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class ServerAccessError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServerAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
function getServerRepository() {
|
||||
return getDataSource().getRepository(ServerEntity);
|
||||
}
|
||||
|
||||
function getMembershipRepository() {
|
||||
return getDataSource().getRepository(ServerMembershipEntity);
|
||||
}
|
||||
|
||||
function getInviteRepository() {
|
||||
return getDataSource().getRepository(ServerInviteEntity);
|
||||
}
|
||||
|
||||
function getBanRepository() {
|
||||
return getDataSource().getRepository(ServerBanEntity);
|
||||
}
|
||||
|
||||
function normalizePassword(password?: string | null): string | null {
|
||||
const normalized = password?.trim() ?? '';
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function hashServerPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function passwordHashForInput(password?: string | null): string | null {
|
||||
const normalized = normalizePassword(password);
|
||||
|
||||
return normalized ? hashServerPassword(normalized) : null;
|
||||
}
|
||||
|
||||
export function buildSignalingUrl(origin: string): string {
|
||||
return origin.replace(/^http/i, 'ws');
|
||||
}
|
||||
|
||||
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
||||
await getInviteRepository()
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expiresAt <= :now', { now })
|
||||
.execute();
|
||||
|
||||
await getBanRepository()
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
||||
return await getServerRepository().findOne({ where: { id: serverId } });
|
||||
}
|
||||
|
||||
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
||||
const banRepo = getBanRepository();
|
||||
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
||||
|
||||
if (!ban)
|
||||
return null;
|
||||
|
||||
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
||||
await banRepo.delete({ id: ban.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
return ban;
|
||||
}
|
||||
|
||||
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
||||
return !!(await getActiveServerBan(serverId, userId));
|
||||
}
|
||||
|
||||
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||
}
|
||||
|
||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
const existing = await repo.findOne({ where: { serverId, userId } });
|
||||
|
||||
if (existing) {
|
||||
existing.lastAccessAt = now;
|
||||
await repo.save(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entity = repo.create({
|
||||
id: uuidv4(),
|
||||
serverId,
|
||||
userId,
|
||||
joinedAt: now,
|
||||
lastAccessAt: now
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
||||
await getMembershipRepository().delete({ serverId, userId });
|
||||
}
|
||||
|
||||
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
||||
const server = await getServerRecord(serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(serverId, requesterUserId)) {
|
||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(serverId, requesterUserId);
|
||||
|
||||
if (server.ownerId !== requesterUserId && !membership) {
|
||||
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function createServerInvite(
|
||||
serverId: string,
|
||||
createdBy: string,
|
||||
createdByDisplayName?: string
|
||||
): Promise<ServerInviteEntity> {
|
||||
await assertCanCreateInvite(serverId, createdBy);
|
||||
|
||||
const repo = getInviteRepository();
|
||||
const now = Date.now();
|
||||
const invite = repo.create({
|
||||
id: uuidv4(),
|
||||
serverId,
|
||||
createdBy,
|
||||
createdByDisplayName: createdByDisplayName ?? null,
|
||||
createdAt: now,
|
||||
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
||||
});
|
||||
|
||||
await repo.save(invite);
|
||||
return invite;
|
||||
}
|
||||
|
||||
export async function getActiveServerInvite(
|
||||
inviteId: string
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||
|
||||
if (!invite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invite.expiresAt <= Date.now()) {
|
||||
await getInviteRepository().delete({ id: invite.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = await getServerRecord(invite.serverId);
|
||||
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { invite, server };
|
||||
}
|
||||
|
||||
export async function joinServerWithAccess(options: {
|
||||
inviteId?: string;
|
||||
password?: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
}): Promise<JoinServerAccessResult> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const server = await getServerRecord(options.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(server.id, options.userId)) {
|
||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
||||
}
|
||||
|
||||
if (options.inviteId) {
|
||||
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
||||
|
||||
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
||||
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
||||
}
|
||||
|
||||
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
if (membership) {
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: rowToServer(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
|
||||
if (server.passwordHash) {
|
||||
const passwordHash = passwordHashForInput(options.password);
|
||||
|
||||
if (!passwordHash || passwordHash !== server.passwordHash) {
|
||||
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
||||
}
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
via: 'password'
|
||||
};
|
||||
}
|
||||
|
||||
if (server.isPrivate) {
|
||||
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
||||
}
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
via: 'public'
|
||||
};
|
||||
}
|
||||
|
||||
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const server = await getServerRecord(serverId);
|
||||
|
||||
if (!server) {
|
||||
return { allowed: false,
|
||||
reason: 'SERVER_NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(serverId, userId)) {
|
||||
return { allowed: false,
|
||||
reason: 'BANNED' };
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(serverId, userId);
|
||||
|
||||
if (membership) {
|
||||
await ensureServerMembership(serverId, userId);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (!server.isPrivate && !server.passwordHash) {
|
||||
await ensureServerMembership(serverId, userId);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
||||
await removeServerMembership(serverId, userId);
|
||||
}
|
||||
|
||||
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
||||
await removeServerMembership(options.serverId, options.userId);
|
||||
|
||||
const repo = getBanRepository();
|
||||
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
||||
|
||||
if (existing) {
|
||||
await repo.delete({ id: existing.id });
|
||||
}
|
||||
|
||||
const entity = repo.create({
|
||||
id: options.banId ?? uuidv4(),
|
||||
serverId: options.serverId,
|
||||
userId: options.userId,
|
||||
bannedBy: options.bannedBy,
|
||||
displayName: options.displayName ?? null,
|
||||
reason: options.reason ?? null,
|
||||
expiresAt: options.expiresAt ?? null,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
||||
const repo = getBanRepository();
|
||||
|
||||
if (options.banId) {
|
||||
await repo.delete({ id: options.banId, serverId: options.serverId });
|
||||
}
|
||||
|
||||
if (options.userId) {
|
||||
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -23,8 +24,24 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
}
|
||||
|
||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = String(message['serverId']);
|
||||
|
||||
if (!sid)
|
||||
return;
|
||||
|
||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||
|
||||
if (!authorization.allowed) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'access_denied',
|
||||
serverId: sid,
|
||||
reason: authorization.reason
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
|
||||
user.serverIds.add(sid);
|
||||
@@ -121,7 +138,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (!user)
|
||||
@@ -133,7 +150,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
||||
break;
|
||||
|
||||
case 'join_server':
|
||||
handleJoinServer(user, message, connectionId);
|
||||
await handleJoinServer(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'view_server':
|
||||
|
||||
@@ -77,11 +77,11 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
handleWebSocketMessage(connectionId, message);
|
||||
await handleWebSocketMessage(connectionId, message);
|
||||
} catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user