Compare commits

..

1 Commits

Author SHA1 Message Date
Myx
c8814c3e2c Add webapp release
Some checks failed
Queue Release Build / prepare (push) Successful in 22s
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
2026-03-12 13:36:39 +01:00
482 changed files with 11193 additions and 37291 deletions

View File

@@ -1,5 +1,4 @@
# Toggle SSL for local development (true/false) # Toggle SSL for local development (true/false)
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https:// # When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
# When false: plain HTTP everywhere (only works on localhost) # When false: plain HTTP everywhere (only works on localhost)
# Overrides server/data/variables.json for local development only
SSL=true SSL=true

View File

@@ -67,10 +67,8 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
cd toju-app
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
cd ..
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
cd server cd server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -122,10 +120,8 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
Push-Location "toju-app"
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
Pop-Location
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
Push-Location server Push-Location server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js

7
.gitignore vendored
View File

@@ -6,9 +6,7 @@
/tmp /tmp
/out-tsc /out-tsc
/bazel-out /bazel-out
*.sqlite
*/architecture.md
/docs
# Node # Node
/node_modules /node_modules
npm-debug.log npm-debug.log
@@ -53,6 +51,3 @@ Thumbs.db
.certs/ .certs/
/server/data/variables.json /server/data/variables.json
dist-server/* dist-server/*
AGENTS.md
doc/**

View File

@@ -1,14 +1,10 @@
<img src="./images/icon.png" width="100" height="100">
# Toju / Zoracord # Toju / Zoracord
Desktop chat app with four parts: Desktop chat app with three parts:
- `src/` Angular client - `src/` Angular client
- `electron/` desktop shell, IPC, and local database - `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events - `server/` directory server, join request API, and websocket events
- `website/` Toju website served at toju.app
## Install ## Install
@@ -21,7 +17,7 @@ Desktop chat app with four parts:
Root `.env`: Root `.env`:
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode - `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
- `PORT=3001` changes the server port in local development and overrides the server app setting - `PORT=3001` changes the server port
If `SSL=true`, run `./generate-cert.sh` once. If `SSL=true`, run `./generate-cert.sh` once.
@@ -29,10 +25,6 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates - `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands ## Main commands
@@ -56,64 +48,3 @@ Inside `server/`:
- `npm run dev` starts the server with reload - `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/` - `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server - `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description |
|------|-------------|
| `src/app/` | Main application root |
| `src/app/core/` | Core utilities, services, models |
| `src/app/domains/` | Domain-driven modules |
| `src/app/features/` | UI feature modules |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
| `src/app/shared/` | Shared UI components |
| `src/app/shared-kernel/` | Shared domain contracts & models |
| `src/app/store/` | Global state management |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
---
### Domains
| Path | Link |
|------|------|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
---
### Infrastructure
| Path | Link |
|------|------|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "../node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "npm",
@@ -62,28 +62,27 @@
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss",
"../node_modules/prismjs/themes/prism-okaidia.css" "node_modules/prismjs/themes/prism-okaidia.css"
], ],
"scripts": [ "scripts": [
"../node_modules/prismjs/prism.js", "node_modules/prismjs/prism.js",
"../node_modules/prismjs/components/prism-markup.min.js", "node_modules/prismjs/components/prism-markup.min.js",
"../node_modules/prismjs/components/prism-clike.min.js", "node_modules/prismjs/components/prism-clike.min.js",
"../node_modules/prismjs/components/prism-javascript.min.js", "node_modules/prismjs/components/prism-javascript.min.js",
"../node_modules/prismjs/components/prism-typescript.min.js", "node_modules/prismjs/components/prism-typescript.min.js",
"../node_modules/prismjs/components/prism-css.min.js", "node_modules/prismjs/components/prism-css.min.js",
"../node_modules/prismjs/components/prism-scss.min.js", "node_modules/prismjs/components/prism-scss.min.js",
"../node_modules/prismjs/components/prism-json.min.js", "node_modules/prismjs/components/prism-json.min.js",
"../node_modules/prismjs/components/prism-bash.min.js", "node_modules/prismjs/components/prism-bash.min.js",
"../node_modules/prismjs/components/prism-markdown.min.js", "node_modules/prismjs/components/prism-markdown.min.js",
"../node_modules/prismjs/components/prism-yaml.min.js", "node_modules/prismjs/components/prism-yaml.min.js",
"../node_modules/prismjs/components/prism-python.min.js", "node_modules/prismjs/components/prism-python.min.js",
"../node_modules/prismjs/components/prism-csharp.min.js" "node_modules/prismjs/components/prism-csharp.min.js"
], ],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"simple-peer", "simple-peer",
"uuid" "uuid"
], ]
"outputPath": "../dist/client"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -96,8 +95,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "1MB",
"maximumError": "2.3MB" "maximumError": "2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

6
dev.sh
View File

@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh" "$DIR/generate-cert.sh"
fi fi
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key" NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
WAIT_URL="https://localhost:4200" WAIT_URL="https://localhost:4200"
HEALTH_URL="https://localhost:3001/api/health" HEALTH_URL="https://localhost:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0 export NODE_TLS_REJECT_UNAUTHORIZED=0
else else
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0" NG_SERVE="ng serve --host=0.0.0.0"
WAIT_URL="http://localhost:4200" WAIT_URL="http://localhost:4200"
HEALTH_URL="http://localhost:3001/api/health" HEALTH_URL="http://localhost:3001/api/health"
fi fi
@@ -33,4 +33,4 @@ fi
exec npx concurrently --kill-others \ exec npx concurrently --kill-others \
"cd server && npm run dev" \ "cd server && npm run dev" \
"$NG_SERVE" \ "$NG_SERVE" \
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage" "wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"

View File

@@ -1,129 +0,0 @@
import { app } from 'electron';
import AutoLaunch from 'auto-launch';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let autoLauncher: AutoLaunch | null = null;
let autoLaunchPath = '';
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
function resolveLaunchPath(): string {
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
const appImagePath = process.platform === 'linux'
? String(process.env['APPIMAGE'] || '').trim()
: '';
return appImagePath || process.execPath;
}
function escapeDesktopEntryExecArgument(argument: string): string {
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
return /[\s"]/u.test(argument)
? `"${escapedArgument}"`
: escapedArgument;
}
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
}
function buildLinuxAutoStartExecLine(launchPath: string): string {
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
}
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
const appName = path.basename(launchPath);
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
`Name=${appName}`,
`Comment=${appName}startup script`,
buildLinuxAutoStartExecLine(launchPath),
'StartupNotify=false',
'Terminal=false'
].join('\n');
}
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
if (process.platform !== 'linux') {
return;
}
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
const execLine = buildLinuxAutoStartExecLine(launchPath);
let currentDesktopEntry = '';
try {
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
} catch {
// Create the desktop entry if auto-launch did not leave one behind.
}
const nextDesktopEntry = currentDesktopEntry
? /^Exec=.*$/m.test(currentDesktopEntry)
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
: buildLinuxAutoStartDesktopEntry(launchPath);
if (nextDesktopEntry === currentDesktopEntry) {
return;
}
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
}
function getAutoLauncher(): AutoLaunch | null {
if (!app.isPackaged) {
return null;
}
if (!autoLauncher) {
autoLaunchPath = resolveLaunchPath();
autoLauncher = new AutoLaunch({
name: app.getName(),
path: autoLaunchPath
});
}
return autoLauncher;
}
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
const launcher = getAutoLauncher();
if (!launcher) {
return;
}
const currentlyEnabled = await launcher.isEnabled();
if (!enabled && currentlyEnabled === enabled) {
return;
}
if (enabled) {
if (!currentlyEnabled) {
await launcher.enable();
}
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
return;
}
await launcher.disable();
}
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
try {
await setAutoStartEnabled(enabled);
} catch {
// Auto-launch integration should never block app startup or settings saves.
}
}

View File

@@ -1,121 +0,0 @@
import { app } from 'electron';
import * as path from 'path';
import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
let pendingDeepLink: string | null = null;
function resolveDevSingleInstanceExitCode(): number | null {
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
if (!rawValue) {
return null;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isInteger(parsedValue) && parsedValue > 0
? parsedValue
: null;
}
function extractDeepLink(argv: string[]): string | null {
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
}
function focusMainWindow(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
}
function forwardDeepLink(url: string): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
pendingDeepLink = url;
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
void createWindow();
}
return;
}
focusMainWindow();
mainWindow.webContents.send('deep-link-received', url);
}
function registerProtocolClient(): void {
if (process.defaultApp) {
const appEntrypoint = process.argv[1];
if (appEntrypoint) {
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
return;
}
}
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
}
export function initializeDeepLinkHandling(): boolean {
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
const devExitCode = resolveDevSingleInstanceExitCode();
if (devExitCode != null) {
app.exit(devExitCode);
} else {
app.quit();
}
return false;
}
registerProtocolClient();
const initialDeepLink = extractDeepLink(process.argv);
if (initialDeepLink) {
pendingDeepLink = initialDeepLink;
}
app.on('second-instance', (_event, argv) => {
focusMainWindow();
const deepLink = extractDeepLink(argv);
if (deepLink) {
forwardDeepLink(deepLink);
}
});
app.on('open-url', (event, url) => {
event.preventDefault();
forwardDeepLink(url);
});
return true;
}
export function consumePendingDeepLink(): string | null {
const deepLink = pendingDeepLink;
pendingDeepLink = null;
return deepLink;
}

View File

@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
app.commandLine.appendSwitch('no-sandbox'); app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage'); app.commandLine.appendSwitch('disable-dev-shm-usage');
// Chromium chooses the Linux Ozone platform before Electron runs this file. // Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland // works for screen capture on Wayland compositors
// sessions so the browser process selects the correct backend early enough. app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
} }
function networkFlags(): void { function networkFlags(): void {

View File

@@ -1,19 +1,12 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing'; import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater'; import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start';
import { import {
initializeDatabase, initializeDatabase,
destroyDatabase, destroyDatabase,
getDataSource getDataSource
} from '../db/database'; } from '../db/database';
import { import { createWindow, getDockIconPath } from '../window/create-window';
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import { import {
setupCqrsHandlers, setupCqrsHandlers,
setupSystemHandlers, setupSystemHandlers,
@@ -31,18 +24,12 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers(); setupCqrsHandlers();
setupWindowControlHandlers(); setupWindowControlHandlers();
setupSystemHandlers(); setupSystemHandlers();
await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
await createWindow(); await createWindow();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0) if (BrowserWindow.getAllWindows().length === 0)
void createWindow(); createWindow();
}); });
}); });
@@ -52,8 +39,6 @@ export function registerAppLifecycle(): void {
}); });
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();

View File

@@ -32,11 +32,6 @@ interface SinkInputDetails extends ShortSinkInputEntry {
properties: Record<string, string>; properties: Record<string, string>;
} }
interface DescendantProcessInfo {
ids: ReadonlySet<string>;
binaryNames: ReadonlySet<string>;
}
interface PactlJsonSinkInputEntry { interface PactlJsonSinkInputEntry {
index?: number | string; index?: number | string;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
@@ -49,7 +44,6 @@ interface LinuxScreenShareAudioRoutingState {
screenShareLoopbackModuleId: string | null; screenShareLoopbackModuleId: string | null;
voiceLoopbackModuleId: string | null; voiceLoopbackModuleId: string | null;
rerouteIntervalId: ReturnType<typeof setInterval> | null; rerouteIntervalId: ReturnType<typeof setInterval> | null;
subscribeProcess: ChildProcess | null;
} }
interface LinuxScreenShareMonitorCaptureState { interface LinuxScreenShareMonitorCaptureState {
@@ -83,8 +77,7 @@ const routingState: LinuxScreenShareAudioRoutingState = {
restoreSinkName: null, restoreSinkName: null,
screenShareLoopbackModuleId: null, screenShareLoopbackModuleId: null,
voiceLoopbackModuleId: null, voiceLoopbackModuleId: null,
rerouteIntervalId: null, rerouteIntervalId: null
subscribeProcess: null
}; };
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = { const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
captureId: null, captureId: null,
@@ -133,21 +126,12 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName); routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName); routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
// Set the default sink to the voice sink so that new app audio await setDefaultSink(SCREEN_SHARE_SINK_NAME);
// streams (received WebRTC voice) never land on the screenshare await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
// capture sink. This prevents the feedback loop where remote
// voice audio was picked up by parec before the reroute interval
// could move the stream away.
await setDefaultSink(VOICE_SINK_NAME);
routingState.active = true; routingState.active = true;
await rerouteAppSinkInputsToVoiceSink();
// Let the combined reroute decide placement for every existing
// stream. This avoids briefly shoving the app's own playback to the
// screenshare sink before ownership detection can move it back.
await rerouteSinkInputs();
startSinkInputRerouteLoop(); startSinkInputRerouteLoop();
startSubscribeWatcher();
return buildRoutingInfo(true, true); return buildRoutingInfo(true, true);
} catch (error) { } catch (error) {
@@ -164,7 +148,6 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> { export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
const restoreSinkName = routingState.restoreSinkName; const restoreSinkName = routingState.restoreSinkName;
stopSubscribeWatcher();
stopSinkInputRerouteLoop(); stopSinkInputRerouteLoop();
await stopLinuxScreenShareMonitorCapture(); await stopLinuxScreenShareMonitorCapture();
@@ -183,7 +166,6 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
routingState.restoreSinkName = null; routingState.restoreSinkName = null;
routingState.screenShareLoopbackModuleId = null; routingState.screenShareLoopbackModuleId = null;
routingState.voiceLoopbackModuleId = null; routingState.voiceLoopbackModuleId = null;
routingState.subscribeProcess = null;
return true; return true;
} }
@@ -443,52 +425,34 @@ async function setDefaultSink(sinkName: string): Promise<void> {
await runPactl('set-default-sink', sinkName); await runPactl('set-default-sink', sinkName);
} }
/** async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
* Combined reroute that enforces sink placement in both directions:
* - App-owned sink inputs that are NOT on the voice sink are moved there.
* - Non-app sink inputs that ARE on the voice sink are moved to the
* screenshare sink so they are captured by parec.
*
* This two-way approach, combined with the voice sink being the PulseAudio
* default, ensures that received WebRTC voice audio can never leak into the
* screenshare monitor source.
*/
async function rerouteSinkInputs(): Promise<void> {
const [ const [
sinks, sinks,
sinkInputs, sinkInputs,
descendantProcessInfo descendantProcessIds
] = await Promise.all([ ] = await Promise.all([
listSinks(), listSinks(),
listSinkInputDetails(), listSinkInputDetails(),
collectDescendantProcessInfo(process.pid) collectDescendantProcessIds(process.pid)
]); ]);
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name])); const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
await Promise.all( await Promise.all(
sinkInputs.map(async (sinkInput) => { sinkInputs.map(async (sinkInput) => {
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null; if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
// App-owned streams must stay on the voice sink.
if (appOwned && sinkName !== VOICE_SINK_NAME) {
try {
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
} catch {
// Streams can disappear or be recreated while rerouting.
}
return; return;
} }
// Non-app streams sitting on the voice sink should be moved to the const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
// screenshare sink for desktop-audio capture.
if (!appOwned && sinkName === VOICE_SINK_NAME) { if (sinkName === VOICE_SINK_NAME) {
try { return;
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME); }
} catch {
// Streams can disappear or be recreated while rerouting. try {
} await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
} catch {
// Streams can disappear or be recreated while rerouting.
} }
}) })
); );
@@ -551,7 +515,7 @@ function startSinkInputRerouteLoop(): void {
} }
routingState.rerouteIntervalId = setInterval(() => { routingState.rerouteIntervalId = setInterval(() => {
void rerouteSinkInputs(); void rerouteAppSinkInputsToVoiceSink();
}, REROUTE_INTERVAL_MS); }, REROUTE_INTERVAL_MS);
} }
@@ -564,108 +528,13 @@ function stopSinkInputRerouteLoop(): void {
routingState.rerouteIntervalId = null; routingState.rerouteIntervalId = null;
} }
/**
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
* When a new or changed sink-input is detected, a reroute is triggered
* immediately instead of waiting for the next interval tick. This
* drastically reduces the time non-app desktop audio spends on the
* voice sink before being moved to the screenshare sink.
*/
function startSubscribeWatcher(): void {
if (routingState.subscribeProcess) {
return;
}
let proc: ChildProcess;
try {
proc = spawn('pactl', ['subscribe'], {
env: process.env,
stdio: [
'ignore',
'pipe',
'ignore'
]
});
} catch {
// If pactl subscribe fails to spawn, the interval loop still covers us.
return;
}
routingState.subscribeProcess = proc;
let pending = false;
proc.stdout?.on('data', (chunk: Buffer) => {
if (!routingState.active) {
return;
}
const text = chunk.toString();
if (/Event '(?:new|change)' on sink-input/.test(text)) {
if (!pending) {
pending = true;
// Batch rapid-fire events with a short delay.
setTimeout(() => {
pending = false;
void rerouteSinkInputs();
}, 50);
}
}
});
proc.on('close', () => {
if (routingState.subscribeProcess === proc) {
routingState.subscribeProcess = null;
}
});
proc.on('error', () => {
if (routingState.subscribeProcess === proc) {
routingState.subscribeProcess = null;
}
});
}
function stopSubscribeWatcher(): void {
const proc = routingState.subscribeProcess;
if (!proc) {
return;
}
routingState.subscribeProcess = null;
if (!proc.killed) {
proc.kill('SIGTERM');
}
}
function isAppOwnedSinkInput( function isAppOwnedSinkInput(
sinkInput: SinkInputDetails, sinkInput: SinkInputDetails,
descendantProcessInfo: DescendantProcessInfo descendantProcessIds: ReadonlySet<string>
): boolean { ): boolean {
const processId = sinkInput.properties['application.process.id']; const processId = sinkInput.properties['application.process.id'];
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) { return typeof processId === 'string' && descendantProcessIds.has(processId);
return true;
}
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
return true;
}
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
return true;
}
return false;
} }
async function moveSinkInputs( async function moveSinkInputs(
@@ -828,45 +697,31 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
return entries.filter((entry) => !!entry.sinkIndex); return entries.filter((entry) => !!entry.sinkIndex);
} }
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> { async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], { const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
env: process.env env: process.env
}); });
const childrenByParentId = new Map<string, string[]>(); const childrenByParentId = new Map<string, string[]>();
const binaryNameByProcessId = new Map<string, string>();
stdout stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.forEach((line) => { .forEach((line) => {
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/); const [pid, ppid] = line.split(/\s+/);
if (!match) { if (!pid || !ppid) {
return; return;
} }
const [
,
pid,
ppid,
command
] = match;
const siblings = childrenByParentId.get(ppid) ?? []; const siblings = childrenByParentId.get(ppid) ?? [];
siblings.push(pid); siblings.push(pid);
childrenByParentId.set(ppid, siblings); childrenByParentId.set(ppid, siblings);
const normalizedBinaryName = normalizeProcessBinary(command);
if (normalizedBinaryName) {
binaryNameByProcessId.set(pid, normalizedBinaryName);
}
}); });
const rootId = `${rootProcessId}`; const rootId = `${rootProcessId}`;
const descendantIds = new Set<string>([rootId]); const descendantIds = new Set<string>([rootId]);
const descendantBinaryNames = new Set<string>();
const queue = [rootId]; const queue = [rootId];
while (queue.length > 0) { while (queue.length > 0) {
@@ -876,12 +731,6 @@ async function collectDescendantProcessInfo(rootProcessId: number): Promise<Desc
continue; continue;
} }
const binaryName = binaryNameByProcessId.get(currentId);
if (binaryName) {
descendantBinaryNames.add(binaryName);
}
for (const childId of childrenByParentId.get(currentId) ?? []) { for (const childId of childrenByParentId.get(currentId) ?? []) {
if (descendantIds.has(childId)) { if (descendantIds.has(childId)) {
continue; continue;
@@ -892,30 +741,7 @@ async function collectDescendantProcessInfo(rootProcessId: number): Promise<Desc
} }
} }
return { return descendantIds;
ids: descendantIds,
binaryNames: descendantBinaryNames
};
}
function normalizeProcessBinary(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const basename = trimmed
.split(/[\\/]/)
.pop()
?.trim()
.toLowerCase() ?? '';
return basename || null;
} }
function stripSurroundingQuotes(value: string): string { function stripSurroundingQuotes(value: string): string {

View File

@@ -3,11 +3,6 @@ import {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -18,11 +13,6 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(MessageEntity).clear(); await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear(); await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear(); await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(RoomRoleEntity).clear();
await dataSource.getRepository(RoomUserRoleEntity).clear();
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
await dataSource.getRepository(ReactionEntity).clear(); await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear(); await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear(); await dataSource.getRepository(AttachmentEntity).clear();

View File

@@ -1,25 +1,10 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import { RoomEntity, MessageEntity } from '../../../entities';
RoomChannelPermissionEntity,
RoomChannelEntity,
RoomEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types'; import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> { export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload; const { roomId } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId }); await dataSource.getRepository(MessageEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId });
});
} }

View File

@@ -1,27 +1,23 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { SaveMessageCommand } from '../../types'; import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> { export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload; const { message } = command.payload;
const entity = repo.create({
await dataSource.transaction(async (manager) => { id: message.id,
const repo = manager.getRepository(MessageEntity); roomId: message.roomId,
const entity = repo.create({ channelId: message.channelId ?? null,
id: message.id, senderId: message.senderId,
roomId: message.roomId, senderName: message.senderName,
channelId: message.channelId ?? null, content: message.content,
senderId: message.senderId, timestamp: message.timestamp,
senderName: message.senderName, editedAt: message.editedAt ?? null,
content: message.content, reactions: JSON.stringify(message.reactions ?? []),
timestamp: message.timestamp, isDeleted: message.isDeleted ? 1 : 0,
editedAt: message.editedAt ?? null, replyToId: message.replyToId ?? null
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null
});
await repo.save(entity);
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
}); });
await repo.save(entity);
} }

View File

@@ -1,55 +1,27 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types'; import { SaveRoomCommand } from '../../types';
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
return room.slowModeInterval;
}
const permissions = room.permissions && typeof room.permissions === 'object'
? room.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: 0;
}
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> { export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload; const { room } = command.payload;
const entity = repo.create({
await dataSource.transaction(async (manager) => { id: room.id,
const repo = manager.getRepository(RoomEntity); name: room.name,
const entity = repo.create({ description: room.description ?? null,
id: room.id, topic: room.topic ?? null,
name: room.name, hostId: room.hostId,
description: room.description ?? null, password: room.password ?? null,
topic: room.topic ?? null, isPrivate: room.isPrivate ? 1 : 0,
hostId: room.hostId, createdAt: room.createdAt,
password: room.password ?? null, userCount: room.userCount ?? 0,
hasPassword: room.hasPassword ? 1 : 0, maxUsers: room.maxUsers ?? null,
isPrivate: room.isPrivate ? 1 : 0, icon: room.icon ?? null,
createdAt: room.createdAt, iconUpdatedAt: room.iconUpdatedAt ?? null,
userCount: room.userCount ?? 0, permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
maxUsers: room.maxUsers ?? null, channels: room.channels != null ? JSON.stringify(room.channels) : null,
icon: room.icon ?? null, members: room.members != null ? JSON.stringify(room.members) : null
iconUpdatedAt: room.iconUpdatedAt ?? null,
slowModeInterval: extractSlowModeInterval(room),
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
});
await repo.save(entity);
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? [],
roles: room.roles ?? [],
roleAssignments: room.roleAssignments ?? [],
channelPermissions: room.channelPermissions ?? [],
permissions: room.permissions
});
}); });
await repo.save(entity);
} }

View File

@@ -1,46 +1,41 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { UpdateMessageCommand } from '../../types'; import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload; const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
await dataSource.transaction(async (manager) => { if (!existing)
const repo = manager.getRepository(MessageEntity); return;
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing) if (updates.channelId !== undefined)
return; existing.channelId = updates.channelId ?? null;
if (updates.channelId !== undefined) if (updates.senderId !== undefined)
existing.channelId = updates.channelId ?? null; existing.senderId = updates.senderId;
if (updates.senderId !== undefined) if (updates.senderName !== undefined)
existing.senderId = updates.senderId; existing.senderName = updates.senderName;
if (updates.senderName !== undefined) if (updates.content !== undefined)
existing.senderName = updates.senderName; existing.content = updates.content;
if (updates.content !== undefined) if (updates.timestamp !== undefined)
existing.content = updates.content; existing.timestamp = updates.timestamp;
if (updates.timestamp !== undefined) if (updates.editedAt !== undefined)
existing.timestamp = updates.timestamp; existing.editedAt = updates.editedAt ?? null;
if (updates.editedAt !== undefined) if (updates.reactions !== undefined)
existing.editedAt = updates.editedAt ?? null; existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined) if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0; existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined) if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null; existing.replyToId = updates.replyToId ?? null;
await repo.save(existing); await repo.save(existing);
if (updates.reactions !== undefined) {
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
}
});
} }

View File

@@ -1,68 +1,30 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { UpdateRoomCommand } from '../../types'; import { UpdateRoomCommand } from '../../types';
import { import {
applyUpdates, applyUpdates,
boolToInt, boolToInt,
jsonOrNull,
TransformMap TransformMap
} from './utils/applyUpdates'; } from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = { const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt, isPrivate: boolToInt,
userCount: (val) => (val ?? 0) userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull,
members: jsonOrNull
}; };
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
return updates.slowModeInterval;
}
const permissions = updates.permissions && typeof updates.permissions === 'object'
? updates.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: undefined;
}
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload; const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
await dataSource.transaction(async (manager) => { if (!existing)
const repo = manager.getRepository(RoomEntity); return;
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing) applyUpdates(existing, updates, ROOM_TRANSFORMS);
return; await repo.save(existing);
const {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions,
...entityUpdates
} = updates;
const slowModeInterval = extractSlowModeInterval(updates);
if (slowModeInterval !== undefined) {
entityUpdates.slowModeInterval = slowModeInterval;
}
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions
});
});
} }

View File

@@ -9,19 +9,10 @@ import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity'; import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity'; import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity'; import { AttachmentEntity } from '../entities/AttachmentEntity';
import { ReactionPayload } from './types';
import {
relationRecordToRoomPayload,
RoomChannelPermissionRecord,
RoomChannelRecord,
RoomMemberRecord,
RoomRoleAssignmentRecord,
RoomRoleRecord
} from './relations';
const DELETED_MESSAGE_CONTENT = '[Message deleted]'; const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) { export function rowToMessage(row: MessageEntity) {
const isDeleted = !!row.isDeleted; const isDeleted = !!row.isDeleted;
return { return {
@@ -33,7 +24,7 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content, content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp, timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined, editedAt: row.editedAt ?? undefined,
reactions: isDeleted ? [] : reactions, reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
isDeleted, isDeleted,
replyToId: row.replyToId ?? undefined replyToId: row.replyToId ?? undefined
}; };
@@ -58,30 +49,7 @@ export function rowToUser(row: UserEntity) {
}; };
} }
export function rowToRoom( export function rowToRoom(row: RoomEntity) {
row: RoomEntity,
relations: {
channels?: RoomChannelRecord[];
members?: RoomMemberRecord[];
roles?: RoomRoleRecord[];
roleAssignments?: RoomRoleAssignmentRecord[];
channelPermissions?: RoomChannelPermissionRecord[];
} = {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
) {
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
channels: relations.channels ?? [],
members: relations.members ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -89,23 +57,15 @@ export function rowToRoom(
topic: row.topic ?? undefined, topic: row.topic ?? undefined,
hostId: row.hostId, hostId: row.hostId,
password: row.password ?? undefined, password: row.password ?? undefined,
hasPassword: !!row.hasPassword,
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
createdAt: row.createdAt, createdAt: row.createdAt,
userCount: row.userCount, userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined, maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined, icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined, iconUpdatedAt: row.iconUpdatedAt ?? undefined,
slowModeInterval: row.slowModeInterval, permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
permissions: relationPayload.permissions, channels: row.channels ? JSON.parse(row.channels) : undefined,
channels: relationPayload.channels, members: row.members ? JSON.parse(row.members) : undefined
members: relationPayload.members,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined
}; };
} }

View File

@@ -1,12 +1,10 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers'; import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetAllRooms(dataSource: DataSource) { export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity); const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find(); const rows = await repo.find();
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id))); return rows.map(rowToRoom);
} }

View File

@@ -2,17 +2,10 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types'; import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers'; import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) { export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } }); const row = await repo.findOne({ where: { id: query.payload.messageId } });
if (!row) { return row ? rowToMessage(row) : null;
return null;
}
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
} }

View File

@@ -2,7 +2,6 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types'; import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers'; import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) { export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
@@ -13,7 +12,6 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
take: limit, take: limit,
skip: offset skip: offset
}); });
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? [])); return rows.map(rowToMessage);
} }

View File

@@ -1,20 +0,0 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const rows = await repo.find({
where: {
roomId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }
});
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
}

View File

@@ -2,17 +2,10 @@ import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types'; import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers'; import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) { export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity); const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } }); const row = await repo.findOne({ where: { id: query.payload.roomId } });
if (!row) { return row ? rowToRoom(row) : null;
return null;
}
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
return rowToRoom(row, relationsByRoomId.get(row.id));
} }

View File

@@ -4,7 +4,6 @@ import {
QueryTypeKey, QueryTypeKey,
Query, Query,
GetMessagesQuery, GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery, GetMessageByIdQuery,
GetReactionsForMessageQuery, GetReactionsForMessageQuery,
GetUserQuery, GetUserQuery,
@@ -14,7 +13,6 @@ import {
GetAttachmentsForMessageQuery GetAttachmentsForMessageQuery
} from '../types'; } from '../types';
import { handleGetMessages } from './handlers/getMessages'; import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById'; import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser'; import { handleGetUser } from './handlers/getUser';
@@ -29,7 +27,6 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource), [QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource), [QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = { export const QueryType = {
GetMessages: 'get-messages', GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id', GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message', GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user', GetUser: 'get-user',
@@ -61,44 +60,6 @@ export interface ReactionPayload {
timestamp: number; timestamp: number;
} }
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type RoomPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: RoomPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface UserPayload { export interface UserPayload {
id: string; id: string;
oderId?: string; oderId?: string;
@@ -123,23 +84,15 @@ export interface RoomPayload {
topic?: string; topic?: string;
hostId: string; hostId: string;
password?: string; password?: string;
hasPassword?: boolean;
isPrivate?: boolean; isPrivate?: boolean;
createdAt: number; createdAt: number;
userCount?: number; userCount?: number;
maxUsers?: number; maxUsers?: number;
icon?: string; icon?: string;
iconUpdatedAt?: number; iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: unknown; permissions?: unknown;
channels?: unknown[]; channels?: unknown[];
members?: unknown[]; members?: unknown[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
} }
export interface BanPayload { export interface BanPayload {
@@ -203,7 +156,6 @@ export type Command =
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -218,7 +170,6 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query = export type Query =
| GetMessagesQuery | GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery | GetMessageByIdQuery
| GetReactionsForMessageQuery | GetReactionsForMessageQuery
| GetUserQuery | GetUserQuery

View File

@@ -17,11 +17,6 @@ import {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -43,11 +38,6 @@ export const AppDataSource = new DataSource({
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,

View File

@@ -7,11 +7,6 @@ import {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -45,11 +40,6 @@ export async function initializeDatabase(): Promise<void> {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,

View File

@@ -6,8 +6,6 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings { export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -21,8 +19,6 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto', autoUpdateMode: 'auto',
autoStart: true,
closeToTray: true,
hardwareAcceleration: true, hardwareAcceleration: true,
manifestUrls: [], manifestUrls: [],
preferredVersion: null, preferredVersion: null,
@@ -85,12 +81,6 @@ export function readDesktopSettings(): DesktopSettings {
return { return {
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode), autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode ? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -112,12 +102,6 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
}; };
const nextSettings: DesktopSettings = { const nextSettings: DesktopSettings = {
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode), autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration ? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -30,6 +30,9 @@ export class MessageEntity {
@Column('integer', { nullable: true }) @Column('integer', { nullable: true })
editedAt!: number | null; editedAt!: number | null;
@Column('text', { default: '[]' })
reactions!: string;
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
isDeleted!: number; isDeleted!: number;

View File

@@ -1,23 +0,0 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_channels')
export class RoomChannelEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -1,26 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_channel_permissions')
export class RoomChannelPermissionEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -24,9 +24,6 @@ export class RoomEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
password!: string | null; password!: string | null;
@Column('integer', { default: 0 })
hasPassword!: number;
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
isPrivate!: number; isPrivate!: number;
@@ -45,15 +42,12 @@ export class RoomEntity {
@Column('integer', { nullable: true }) @Column('integer', { nullable: true })
iconUpdatedAt!: number | null; iconUpdatedAt!: number | null;
@Column('integer', { default: 0 }) @Column('text', { nullable: true })
slowModeInterval!: number; permissions!: string | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
sourceId!: string | null; channels!: string | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
sourceName!: string | null; members!: string | null;
@Column('text', { nullable: true })
sourceUrl!: string | null;
} }

View File

@@ -1,38 +0,0 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_members')
export class RoomMemberEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
memberKey!: string;
@Column('text')
id!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text')
username!: string;
@Column('text')
displayName!: string;
@Column('text', { nullable: true })
avatarUrl!: string | null;
@Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member';
@Column('integer')
joinedAt!: number;
@Column('integer')
lastSeenAt!: number;
}

View File

@@ -1,59 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_roles')
export class RoomRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -1,23 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_user_roles')
export class RoomUserRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
userKey!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
userId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -1,11 +1,6 @@
export { MessageEntity } from './MessageEntity'; export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity'; export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity'; export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
export { ReactionEntity } from './ReactionEntity'; export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity'; export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity'; export { AttachmentEntity } from './AttachmentEntity';

View File

@@ -4,7 +4,6 @@ import {
desktopCapturer, desktopCapturer,
dialog, dialog,
ipcMain, ipcMain,
Notification,
shell shell
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -29,23 +28,8 @@ import {
getDesktopUpdateState, getDesktopUpdateState,
handleDesktopSettingsChanged, handleDesktopSettingsChanged,
restartToApplyUpdate, restartToApplyUpdate,
readDesktopUpdateServerHealth,
type DesktopUpdateServerContext type DesktopUpdateServerContext
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
import {
deleteSavedTheme,
getSavedThemesPath,
listSavedThemes,
readSavedTheme,
writeSavedTheme
} from '../theme-library';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
@@ -99,63 +83,6 @@ interface ClipboardFilePayload {
path?: string; path?: string;
} }
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
.trim()
.toLowerCase();
if (ozonePlatform === 'wayland') {
return 'Wayland';
}
if (ozonePlatform === 'x11') {
return 'X11';
}
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
.trim()
.toLowerCase();
if (ozonePlatformHint === 'wayland') {
return 'Wayland';
}
if (ozonePlatformHint === 'x11') {
return 'X11';
}
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
.toLowerCase();
if (sessionType === 'wayland') {
return 'Wayland';
}
if (sessionType === 'x11') {
return 'X11';
}
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
return 'Wayland';
}
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
return 'X11';
}
return 'Unknown (Linux)';
}
function isSupportedClipboardFileFormat(format: string): boolean { function isSupportedClipboardFileFormat(format: string): boolean {
return FILE_CLIPBOARD_FORMATS.some( return FILE_CLIPBOARD_FORMATS.some(
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase() (supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
@@ -267,10 +194,6 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
} }
export function setupSystemHandlers(): void { export function setupSystemHandlers(): void {
ipcMain.on('get-linux-display-server', (event) => {
event.returnValue = resolveLinuxDisplayServer();
});
ipcMain.handle('open-external', async (_event, url: string) => { ipcMain.handle('open-external', async (_event, url: string) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) { if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url); await shell.openExternal(url);
@@ -280,8 +203,6 @@ export function setupSystemHandlers(): void {
return false; return false;
}); });
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
ipcMain.handle('get-sources', async () => { ipcMain.handle('get-sources', async () => {
try { try {
const thumbnailSize = { width: 240, height: 150 }; const thumbnailSize = { width: 240, height: 150 };
@@ -332,91 +253,11 @@ export function setupSystemHandlers(): void {
}); });
ipcMain.handle('get-app-data-path', () => app.getPath('userData')); ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
return await writeSavedTheme(fileName, text);
});
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
return await deleteSavedTheme(fileName);
});
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot()); ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
const suppressSystemNotification = mainWindow?.isVisible() === true
&& !mainWindow.isMinimized()
&& mainWindow.isMaximized();
if (!title) {
return false;
}
if (!suppressSystemNotification && Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState()); ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
return await readDesktopUpdateServerHealth(serverUrl);
});
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => { ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context); return await configureDesktopUpdaterContext(context);
}); });
@@ -430,8 +271,6 @@ export function setupSystemHandlers(): void {
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => { ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch); const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged(); await handleDesktopSettingsChanged();
return snapshot; return snapshot;
}); });

View File

@@ -1,10 +1,6 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { initializeDeepLinkHandling } from './app/deep-links';
import { configureAppFlags } from './app/flags'; import { configureAppFlags } from './app/flags';
import { registerAppLifecycle } from './app/lifecycle'; import { registerAppLifecycle } from './app/lifecycle';
configureAppFlags(); configureAppFlags();
registerAppLifecycle();
if (initializeDeepLinkHandling()) {
registerAppLifecycle();
}

View File

@@ -1,26 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
name = 'AddRoomSourceAndPasswordState1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
await queryRunner.query(`
UPDATE "rooms"
SET "hasPassword" = CASE
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
ELSE 0
END
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
}
}

View File

@@ -1,396 +0,0 @@
import { randomUUID } from 'crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyMessageRow = {
id: string;
reactions: string | null;
};
type LegacyRoomRow = {
id: string;
channels: string | null;
members: string | null;
};
type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
type LegacyReaction = {
id?: unknown;
oderId?: unknown;
userId?: unknown;
emoji?: unknown;
timestamp?: unknown;
};
type LegacyRoomChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
type LegacyRoomMember = {
id?: unknown;
oderId?: unknown;
username?: unknown;
displayName?: unknown;
avatarUrl?: unknown;
role?: unknown;
joinedAt?: unknown;
lastSeenAt?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ChannelType, name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || '';
}
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
const base = fallbackDisplayName(member)
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
return base || member.oderId || member.id || 'user';
}
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
? value
: 'member';
}
function mergeRoomMemberRole(
existingRole: RoomMemberRole,
incomingRole: RoomMemberRole,
preferIncoming: boolean
): RoomMemberRole {
if (existingRole === incomingRole) {
return existingRole;
}
if (incomingRole === 'member' && existingRole !== 'member') {
return existingRole;
}
if (existingRole === 'member' && incomingRole !== 'member') {
return incomingRole;
}
return preferIncoming ? incomingRole : existingRole;
}
function compareRoomMembers(
firstMember: {
id: string;
oderId?: string;
displayName: string;
},
secondMember: {
id: string;
oderId?: string;
displayName: string;
}
): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return memberKey(firstMember).localeCompare(memberKey(secondMember));
}
function normalizeMessageReactions(messageId: string, raw: string | null) {
const reactions = parseArray<LegacyReaction>(raw);
const seen = new Set<string>();
return reactions.flatMap((reaction) => {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
const dedupeKey = `${userId}:${emoji}`;
if (!emoji || seen.has(dedupeKey)) {
return [];
}
seen.add(dedupeKey);
return [{
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
messageId,
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
userId: userId || null,
emoji,
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
}];
});
}
function normalizeRoomChannels(raw: string | null) {
const channels = parseArray<LegacyRoomChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
const members = parseArray<LegacyRoomMember>(raw);
const membersByKey = new Map<string, {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
joinedAt: number;
lastSeenAt: number;
}>();
for (const rawMember of members) {
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
const key = normalizedOderId || normalizedId;
if (!key) {
continue;
}
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
? rawMember.lastSeenAt
: isFiniteNumber(rawMember.joinedAt)
? rawMember.joinedAt
: now;
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
const nextMember = {
id: normalizedId || key,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember.role),
joinedAt,
lastSeenAt
};
const existingMember = membersByKey.get(key);
if (!existingMember) {
membersByKey.set(key, nextMember);
continue;
}
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
membersByKey.set(key, {
id: existingMember.id || nextMember.id,
oderId: nextMember.oderId || existingMember.oderId,
username: preferIncoming
? (nextMember.username || existingMember.username)
: (existingMember.username || nextMember.username),
displayName: preferIncoming
? (nextMember.displayName || existingMember.displayName)
: (existingMember.displayName || nextMember.displayName),
avatarUrl: preferIncoming
? (nextMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || nextMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
});
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
name = 'NormalizeArrayColumns1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channels" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_members" (
"roomId" TEXT NOT NULL,
"memberKey" TEXT NOT NULL,
"id" TEXT NOT NULL,
"oderId" TEXT,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"role" TEXT NOT NULL,
"joinedAt" INTEGER NOT NULL,
"lastSeenAt" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "memberKey")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
for (const row of messageRows) {
const reactions = normalizeMessageReactions(row.id, row.reactions);
for (const reaction of reactions) {
const existing = await queryRunner.query(
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
[reaction.messageId, reaction.userId, reaction.emoji]
) as Array<{ 1: number }>;
if (existing.length > 0) {
continue;
}
await queryRunner.query(
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
);
}
}
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
for (const row of roomRows) {
for (const channel of normalizeRoomChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
for (const member of normalizeRoomMembers(row.members)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
memberKey(member),
member.id,
member.oderId ?? null,
member.username,
member.displayName,
member.avatarUrl ?? null,
member.role,
member.joinedAt,
member.lastSeenAt
]
);
}
}
await queryRunner.query(`
CREATE TABLE "messages_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"roomId" TEXT NOT NULL,
"channelId" TEXT,
"senderId" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"editedAt" INTEGER,
"isDeleted" INTEGER NOT NULL DEFAULT 0,
"replyToId" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
FROM "messages"
`);
await queryRunner.query(`DROP TABLE "messages"`);
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"permissions" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`);
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
}
}

View File

@@ -1,310 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyRoomRow = {
id: string;
name: string;
description: string | null;
topic: string | null;
hostId: string;
password: string | null;
hasPassword: number;
isPrivate: number;
createdAt: number;
userCount: number;
maxUsers: number | null;
icon: string | null;
iconUpdatedAt: number | null;
permissions: string | null;
sourceId: string | null;
sourceName: string | null;
sourceUrl: string | null;
};
type RoomMemberRow = {
roomId: string;
memberKey: string;
id: string;
oderId: string | null;
role: string;
};
type LegacyRoomPermissions = {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
try {
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
return {
adminsManageRooms: parsed['adminsManageRooms'] === true,
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
adminsManageIcon: parsed['adminsManageIcon'] === true,
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
allowVoice: parsed['allowVoice'] !== false,
allowScreenShare: parsed['allowScreenShare'] !== false,
allowFileUploads: parsed['allowFileUploads'] !== false,
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
? parsed['slowModeInterval']
: 0
};
} catch {
return {
allowVoice: true,
allowScreenShare: true,
allowFileUploads: true,
slowModeInterval: 0
};
}
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
function roleIdsForMemberRole(role: string): string[] {
if (role === 'admin') {
return [SYSTEM_ROLE_IDS.admin];
}
if (role === 'moderator') {
return [SYSTEM_ROLE_IDS.moderator];
}
return [];
}
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
name = 'NormalizeRoomAccessControl1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_roles" (
"roomId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("roomId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_user_roles" (
"roomId" TEXT NOT NULL,
"userKey" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("roomId", "userKey", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
const rooms = await queryRunner.query(`
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`) as LegacyRoomRow[];
const members = await queryRunner.query(`
SELECT "roomId", "memberKey", "id", "oderId", "role"
FROM "room_members"
`) as RoomMemberRow[];
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
const roles = buildDefaultRoomRoles(legacyPermissions);
for (const role of roles) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
for (const roleId of roleIdsForMemberRole(member.role)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
[
room.id,
member.memberKey,
roleId,
member.id,
member.oderId
]
);
}
}
}
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
await queryRunner.query(
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
room.name,
room.description,
room.topic,
room.hostId,
room.password,
room.hasPassword,
room.isPrivate,
room.createdAt,
room.userCount,
room.maxUsers,
room.icon,
room.iconUpdatedAt,
legacyPermissions.slowModeInterval ?? 0,
room.sourceId,
room.sourceName,
room.sourceUrl
]
);
}
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
}
}

View File

@@ -4,8 +4,6 @@ import { Command, Query } from './cqrs/types';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -51,12 +49,6 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
availableVersions: string[]; availableVersions: string[];
@@ -91,41 +83,7 @@ export interface DesktopUpdateState {
targetVersion: string | null; targetVersion: string | null;
} }
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
export interface SavedThemeFileDescriptor {
fileName: string;
modifiedAt: number;
path: string;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
try {
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
return typeof displayServer === 'string' && displayServer.trim().length > 0
? displayServer
: 'Unknown (Linux)';
} catch {
return 'Unknown (Linux)';
}
}
export interface ElectronAPI { export interface ElectronAPI {
linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
maximizeWindow: () => void; maximizeWindow: () => void;
closeWindow: () => void; closeWindow: () => void;
@@ -140,44 +98,27 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
deleteSavedTheme: (fileName: string) => Promise<boolean>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<{ getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
runtimeHardwareAcceleration: boolean; runtimeHardwareAcceleration: boolean;
restartRequired: boolean; restartRequired: boolean;
}>; }>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>; getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>; configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>; checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>; restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void; onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: { setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version'; autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;
vaapiVideoEncode?: boolean; vaapiVideoEncode?: boolean;
}) => Promise<{ }) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -185,7 +126,6 @@ export interface ElectronAPI {
restartRequired: boolean; restartRequired: boolean;
}>; }>;
relaunchApp: () => Promise<boolean>; relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>; readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>; readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
@@ -199,7 +139,6 @@ export interface ElectronAPI {
} }
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
linuxDisplayServer: readLinuxDisplayServer(),
minimizeWindow: () => ipcRenderer.send('window-minimize'), minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'), maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'), closeWindow: () => ipcRenderer.send('window-close'),
@@ -241,29 +180,8 @@ const electronAPI: ElectronAPI = {
}; };
}, },
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'), getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'), getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context), configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'), checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'), restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
@@ -280,17 +198,6 @@ const electronAPI: ElectronAPI = {
}, },
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch), setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
relaunchApp: () => ipcRenderer.invoke('relaunch-app'), relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
onDeepLinkReceived: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
listener(url);
};
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
};
},
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'), readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),

View File

@@ -1,91 +0,0 @@
import { app } from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface SavedThemeFileDescriptor {
fileName: string;
modifiedAt: number;
path: string;
}
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
function resolveSavedThemesPath(): string {
return path.join(app.getPath('userData'), 'themes');
}
async function ensureSavedThemesPath(): Promise<string> {
const themesPath = resolveSavedThemesPath();
await fsp.mkdir(themesPath, { recursive: true });
return themesPath;
}
function assertSavedThemeFileName(fileName: string): string {
const normalized = typeof fileName === 'string'
? fileName.trim()
: '';
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
throw new Error('Invalid saved theme file name.');
}
return normalized;
}
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
const themesPath = await ensureSavedThemesPath();
return path.join(themesPath, assertSavedThemeFileName(fileName));
}
export async function getSavedThemesPath(): Promise<string> {
return await ensureSavedThemesPath();
}
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
const themesPath = await ensureSavedThemesPath();
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const descriptors = await Promise.all(files.map(async (entry) => {
const filePath = path.join(themesPath, entry.name);
const stats = await fsp.stat(filePath);
return {
fileName: entry.name,
modifiedAt: Math.round(stats.mtimeMs),
path: filePath
} satisfies SavedThemeFileDescriptor;
}));
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
}
export async function readSavedTheme(fileName: string): Promise<string> {
const filePath = await resolveSavedThemeFilePath(fileName);
return await fsp.readFile(filePath, 'utf8');
}
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
const filePath = await resolveSavedThemeFilePath(fileName);
await fsp.writeFile(filePath, text, 'utf8');
return true;
}
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
const filePath = await resolveSavedThemeFilePath(fileName);
try {
await fsp.unlink(filePath);
return true;
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
return true;
}
throw error;
}
}

View File

@@ -18,11 +18,6 @@ interface ReleaseManifestEntry {
version: string; version: string;
} }
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface UpdateVersionInfo { interface UpdateVersionInfo {
version: string; version: string;
} }
@@ -58,12 +53,6 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
availableVersions: string[]; availableVersions: string[];
@@ -89,8 +78,6 @@ export interface DesktopUpdateState {
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
let currentCheckPromise: Promise<void> | null = null; let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = { let currentContext: DesktopUpdateServerContext = {
manifestUrls: [], manifestUrls: [],
@@ -401,47 +388,6 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
return parseReleaseManifest(payload); return parseReleaseManifest(payload);
} }
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
return {
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
if (!sanitizedServerUrl) {
return createUnavailableServerHealthSnapshot();
}
try {
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
headers: {
accept: 'application/json'
},
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
});
if (!response.ok) {
return createUnavailableServerHealthSnapshot();
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
return {
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return createUnavailableServerHealthSnapshot();
}
}
function formatManifestLoadErrors(errors: string[]): string { function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) { if (errors.length === 0) {
return 'No valid release manifest could be loaded.'; return 'No valid release manifest could be loaded.';
@@ -778,12 +724,6 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
return desktopUpdateState; return desktopUpdateState;
} }
export async function readDesktopUpdateServerHealth(
serverUrl: string
): Promise<DesktopUpdateServerHealthSnapshot> {
return await loadServerHealth(serverUrl);
}
export function restartToApplyUpdate(): boolean { export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) { if (!desktopUpdateState.restartRequired) {
return false; return false;

View File

@@ -2,21 +2,13 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
Menu,
session, session,
shell, shell
Tray
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string { function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged const basePath = app.isPackaged
@@ -46,124 +38,13 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png'); return getExistingAssetPath('macos', '1024x1024.png');
} }
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow; return mainWindow;
} }
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath(); const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
@@ -224,46 +105,10 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
} }
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };

View File

@@ -199,7 +199,7 @@ module.exports = tseslint.config(
}, },
// HTML template formatting rules (external Angular templates only) // HTML template formatting rules (external Angular templates only)
{ {
files: ['toju-app/src/app/**/*.html'], files: ['src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin }, plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {

234
package-lock.json generated
View File

@@ -14,12 +14,6 @@
"@angular/forms": "^21.0.0", "@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@ng-icons/core": "^33.0.0", "@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0", "@ng-icons/lucide": "^33.0.0",
"@ngrx/effects": "^21.0.1", "@ngrx/effects": "^21.0.1",
@@ -30,10 +24,8 @@
"@spartan-ng/cli": "^0.0.1-alpha.589", "@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380", "@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0", "@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
@@ -53,12 +45,11 @@
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.0.4", "@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4", "@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -2704,109 +2695,6 @@
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@develar/schema-utils": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -5782,41 +5670,6 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@listr2/prompt-adapter-inquirer": { "node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
@@ -6010,12 +5863,6 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mermaid-js/parser": { "node_modules/@mermaid-js/parser": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
@@ -10969,13 +10816,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/auto-launch": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -13035,11 +12875,6 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/applescript": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
},
"node_modules/arg": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -13133,22 +12968,6 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/auto-launch": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
"license": "MIT",
"dependencies": {
"applescript": "^1.0.0",
"mkdirp": "^0.5.1",
"path-is-absolute": "^1.0.0",
"untildify": "^3.0.2",
"winreg": "1.2.4"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.23", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -14289,21 +14108,6 @@
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -14932,12 +14736,6 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cron-parser": { "node_modules/cron-parser": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@@ -22487,7 +22285,9 @@
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -23945,6 +23745,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -27954,12 +27755,6 @@
"webpack": "^5.0.0" "webpack": "^5.0.0"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/stylehacks": { "node_modules/stylehacks": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
@@ -29776,15 +29571,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/untildify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/upath": { "node_modules/upath": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
@@ -30552,12 +30338,6 @@
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wait-on": { "node_modules/wait-on": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
@@ -31381,12 +31161,6 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/winreg": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
"license": "BSD-2-Clause"
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -7,24 +7,24 @@
"homepage": "https://git.azaaxin.com/myxelium/Toju", "homepage": "https://git.azaaxin.com/myxelium/Toju",
"main": "dist/electron/main.js", "main": "dist/electron/main.js",
"scripts": { "scripts": {
"ng": "cd \"toju-app\" && ng", "ng": "ng",
"prebuild": "npm run bundle:rnnoise", "prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise", "prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js", "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "cd \"toju-app\" && ng serve", "start": "ng serve",
"build": "cd \"toju-app\" && ng build", "build": "ng build",
"build:electron": "tsc -p tsconfig.electron.json", "build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'", "build:prod": "ng build --configuration production --base-href='./'",
"watch": "cd \"toju-app\" && ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "cd \"toju-app\" && ng test", "test": "ng test",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage", "electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh", "electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js", "migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
"migration:create": "typeorm migration:create electron/migrations/New", "migration:create": "typeorm migration:create electron/migrations/New",
"migration:run": "typeorm migration:run -d dist/electron/data-source.js", "migration:run": "typeorm migration:run -d dist/electron/data-source.js",
@@ -40,8 +40,8 @@
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"toju-app/src/app/**/*.html\"", "format": "prettier --write \"src/app/**/*.html\"",
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"", "format:check": "prettier --check \"src/app/**/*.html\"",
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux", "release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win", "release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
"release:manifest": "node tools/generate-release-manifest.js", "release:manifest": "node tools/generate-release-manifest.js",
@@ -60,12 +60,6 @@
"@angular/forms": "^21.0.0", "@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@ng-icons/core": "^33.0.0", "@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0", "@ng-icons/lucide": "^33.0.0",
"@ngrx/effects": "^21.0.1", "@ngrx/effects": "^21.0.1",
@@ -76,10 +70,8 @@
"@spartan-ng/cli": "^0.0.1-alpha.589", "@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380", "@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0", "@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
@@ -104,7 +96,6 @@
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -129,14 +120,6 @@
"build": { "build": {
"appId": "com.metoyou.app", "appId": "com.metoyou.app",
"productName": "MetoYou", "productName": "MetoYou",
"protocols": [
{
"name": "Toju Invite Links",
"schemes": [
"toju"
]
}
],
"directories": { "directories": {
"output": "dist-electron" "output": "dist-electron"
}, },

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

View File

@@ -5,7 +5,6 @@ import { registerRoutes } from './routes';
export function createApp(): express.Express { export function createApp(): express.Express {
const app = express(); const app = express();
app.set('trust proxy', true);
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());

View File

@@ -2,20 +2,13 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { resolveRuntimePath } from '../runtime-paths'; import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https';
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
serverHost: string;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
const DEFAULT_SERVER_PORT = 3001;
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
function normalizeKlipyApiKey(value: unknown): string { function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@@ -25,51 +18,6 @@ function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function normalizeServerHost(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeServerProtocol(
value: unknown,
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
): ServerHttpProtocol {
if (typeof value === 'boolean') {
return value ? 'https' : 'http';
}
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'https' || normalized === 'true') {
return 'https';
}
if (normalized === 'http' || normalized === 'false') {
return 'http';
}
return fallback;
}
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? Number.parseInt(value.trim(), 10)
: Number.NaN;
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
? parsed
: fallback;
}
function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } { function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
if (!fs.existsSync(VARIABLES_FILE)) { if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} }; return { rawContents: '', parsed: {} };
@@ -104,14 +52,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
} }
const { rawContents, parsed } = readRawVariables(); const { rawContents, parsed } = readRawVariables();
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
const normalized = { const normalized = {
...remainingParsed, ...parsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey), klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -121,10 +65,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return { return {
klipyApiKey: normalized.klipyApiKey, klipyApiKey: normalized.klipyApiKey,
releaseManifestUrl: normalized.releaseManifestUrl, releaseManifestUrl: normalized.releaseManifestUrl
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost
}; };
} }
@@ -143,29 +84,3 @@ export function hasKlipyApiKey(): boolean {
export function getReleaseManifestUrl(): string { export function getReleaseManifestUrl(): string {
return getVariablesConfig().releaseManifestUrl; return getVariablesConfig().releaseManifestUrl;
} }
export function getServerProtocol(): ServerHttpProtocol {
if (hasEnvironmentOverride(process.env.SSL)) {
return normalizeServerProtocol(process.env.SSL);
}
return getVariablesConfig().serverProtocol;
}
export function getServerPort(): number {
if (hasEnvironmentOverride(process.env.PORT)) {
return normalizeServerPort(process.env.PORT);
}
return getVariablesConfig().serverPort;
}
export function getServerHost(): string | undefined {
const serverHost = getVariablesConfig().serverHost;
return serverHost || undefined;
}
export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https';
}

View File

@@ -1,31 +1,10 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import { ServerEntity, JoinRequestEntity } from '../../../entities';
ServerChannelPermissionEntity,
ServerChannelEntity,
ServerEntity,
ServerRoleEntity,
ServerTagEntity,
ServerUserRoleEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../../../entities';
import { DeleteServerCommand } from '../../types'; import { DeleteServerCommand } from '../../types';
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> { export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
const { serverId } = command.payload; const { serverId } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerTagEntity).delete({ serverId }); await dataSource.getRepository(ServerEntity).delete(serverId);
await manager.getRepository(ServerChannelEntity).delete({ serverId });
await manager.getRepository(ServerRoleEntity).delete({ serverId });
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
await manager.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
await manager.getRepository(ServerInviteEntity).delete({ serverId });
await manager.getRepository(ServerBanEntity).delete({ serverId });
await manager.getRepository(ServerEntity).delete(serverId);
});
} }

View File

@@ -1,35 +1,23 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities'; import { ServerEntity } from '../../../entities';
import { replaceServerRelations } from '../../relations';
import { UpsertServerCommand } from '../../types'; import { UpsertServerCommand } from '../../types';
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> { export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ServerEntity);
const { server } = command.payload; const { server } = command.payload;
const entity = repo.create({
await dataSource.transaction(async (manager) => { id: server.id,
const repo = manager.getRepository(ServerEntity); name: server.name,
const entity = repo.create({ description: server.description ?? null,
id: server.id, ownerId: server.ownerId,
name: server.name, ownerPublicKey: server.ownerPublicKey,
description: server.description ?? null, isPrivate: server.isPrivate ? 1 : 0,
ownerId: server.ownerId, maxUsers: server.maxUsers,
ownerPublicKey: server.ownerPublicKey, currentUsers: server.currentUsers,
passwordHash: server.passwordHash ?? null, tags: JSON.stringify(server.tags),
isPrivate: server.isPrivate ? 1 : 0, createdAt: server.createdAt,
maxUsers: server.maxUsers, lastSeen: server.lastSeen
currentUsers: server.currentUsers,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
await repo.save(entity);
await replaceServerRelations(manager, server.id, {
tags: server.tags,
channels: server.channels ?? [],
roles: server.roles ?? [],
roleAssignments: server.roleAssignments ?? [],
channelPermissions: server.channelPermissions ?? []
});
}); });
await repo.save(entity);
} }

View File

@@ -6,7 +6,6 @@ import {
ServerPayload, ServerPayload,
JoinRequestPayload JoinRequestPayload
} from './types'; } from './types';
import { relationRecordToServerPayload } from './relations';
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return { return {
@@ -18,41 +17,17 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
}; };
} }
export function rowToServer( export function rowToServer(row: ServerEntity): ServerPayload {
row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
): ServerPayload {
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
tags: relations.tags ?? [],
channels: relations.channels ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
description: row.description ?? undefined, description: row.description ?? undefined,
ownerId: row.ownerId, ownerId: row.ownerId,
ownerPublicKey: row.ownerPublicKey, ownerPublicKey: row.ownerPublicKey,
hasPassword: !!row.passwordHash,
passwordHash: row.passwordHash ?? undefined,
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers, maxUsers: row.maxUsers,
currentUsers: row.currentUsers, currentUsers: row.currentUsers,
slowModeInterval: relationPayload.slowModeInterval, tags: JSON.parse(row.tags || '[]'),
tags: relationPayload.tags,
channels: relationPayload.channels,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
createdAt: row.createdAt, createdAt: row.createdAt,
lastSeen: row.lastSeen lastSeen: row.lastSeen
}; };

View File

@@ -1,12 +1,10 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities'; import { ServerEntity } from '../../../entities';
import { rowToServer } from '../../mappers'; import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetAllPublicServers(dataSource: DataSource) { export async function handleGetAllPublicServers(dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity); const repo = dataSource.getRepository(ServerEntity);
const rows = await repo.find({ where: { isPrivate: 0 } }); const rows = await repo.find({ where: { isPrivate: 0 } });
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id))); return rows.map(rowToServer);
} }

View File

@@ -2,17 +2,10 @@ import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities'; import { ServerEntity } from '../../../entities';
import { GetServerByIdQuery } from '../../types'; import { GetServerByIdQuery } from '../../types';
import { rowToServer } from '../../mappers'; import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) { export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity); const repo = dataSource.getRepository(ServerEntity);
const row = await repo.findOne({ where: { id: query.payload.serverId } }); const row = await repo.findOne({ where: { id: query.payload.serverId } });
if (!row) { return row ? rowToServer(row) : null;
return null;
}
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
return rowToServer(row, relationsByServerId.get(row.id));
} }

View File

@@ -1,603 +0,0 @@
import {
DataSource,
EntityManager,
In
} from 'typeorm';
import {
ServerChannelEntity,
ServerTagEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity
} from '../entities';
import {
AccessRolePayload,
ChannelPermissionPayload,
RoleAssignmentPayload,
ServerChannelPayload,
ServerPayload,
ServerPermissionKeyPayload,
PermissionStatePayload
} from './types';
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
interface ServerRelationRecord {
tags: string[];
channels: ServerChannelPayload[];
roles: AccessRolePayload[];
roleAssignments: RoleAssignmentPayload[];
channelPermissions: ChannelPermissionPayload[];
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of SERVER_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function buildDefaultServerRoles(): AccessRolePayload[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: 'allow',
manageIcon: 'allow'
}
}
];
}
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
export function normalizeServerTags(rawTags: unknown): string[] {
if (!Array.isArray(rawTags)) {
return [];
}
return rawTags
.filter((tag): tag is string => typeof tag === 'string')
.map((tag) => tag.trim())
.filter(Boolean);
}
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
if (!Array.isArray(rawChannels)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, rawChannel] of rawChannels.entries()) {
if (!rawChannel || typeof rawChannel !== 'object') {
continue;
}
const channel = rawChannel as Record<string, unknown>;
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;
}
seenIds.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
const rolesById = new Map<string, AccessRolePayload>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of buildDefaultServerRoles()) {
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
}
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
if (!Array.isArray(rawAssignments)) {
return [];
}
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
const userId = typeof assignment['userId'] === 'string' ? assignment['userId'].trim() : '';
const oderId = typeof assignment['oderId'] === 'string' ? assignment['oderId'].trim() : undefined;
const key = oderId || userId;
if (!key) {
continue;
}
const roleIds = uniqueStrings(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeServerChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly AccessRolePayload[]
): ChannelPermissionPayload[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, ChannelPermissionPayload>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.values()).sort((firstOverride, secondOverride) => {
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
if (channelCompare !== 0) {
return channelCompare;
}
if (firstOverride.targetType !== secondOverride.targetType) {
return compareText(firstOverride.targetType, secondOverride.targetType);
}
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
if (targetCompare !== 0) {
return targetCompare;
}
return compareText(firstOverride.permission, secondOverride.permission);
});
}
export async function replaceServerRelations(
manager: EntityManager,
serverId: string,
options: {
tags: unknown;
channels: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
}
): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity);
const channelRepo = manager.getRepository(ServerChannelEntity);
const roleRepo = manager.getRepository(ServerRoleEntity);
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
const tags = normalizeServerTags(options.tags);
const channels = normalizeServerChannels(options.channels);
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
await tagRepo.delete({ serverId });
await channelRepo.delete({ serverId });
if (options.roles !== undefined) {
await roleRepo.delete({ serverId });
}
if (options.roleAssignments !== undefined) {
await userRoleRepo.delete({ serverId });
}
if (options.channelPermissions !== undefined) {
await channelPermissionRepo.delete({ serverId });
}
if (tags.length > 0) {
await tagRepo.insert(
tags.map((tag, position) => ({
serverId,
position,
value: tag
}))
);
}
if (channels.length > 0) {
await channelRepo.insert(
channels.map((channel) => ({
serverId,
channelId: channel.id,
name: channel.name,
type: channel.type,
position: channel.position
}))
);
}
if (options.roles !== undefined && roles.length > 0) {
await roleRepo.insert(
roles.map((role) => ({
serverId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
if (options.roleAssignments !== undefined) {
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
const rows = roleAssignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
serverId,
userId: assignment.userId,
roleId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await userRoleRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissions = normalizeServerChannelPermissions(
options.channelPermissions,
roles.length > 0 ? roles : normalizeServerRoles([])
);
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
serverId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
}
export async function loadServerRelationsMap(
dataSource: DataSource,
serverIds: readonly string[]
): Promise<Map<string, ServerRelationRecord>> {
const groupedRelations = new Map<string, ServerRelationRecord>();
if (serverIds.length === 0) {
return groupedRelations;
}
const [
tagRows,
channelRows,
roleRows,
userRoleRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerUserRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelPermissionEntity).find({
where: { serverId: In([...serverIds]) }
})
]);
for (const serverId of serverIds) {
groupedRelations.set(serverId, {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
for (const row of tagRows) {
groupedRelations.get(row.serverId)?.tags.push(row.value);
}
for (const row of channelRows) {
groupedRelations.get(row.serverId)?.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
}
for (const row of roleRows) {
groupedRelations.get(row.serverId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of userRoleRows) {
const relation = groupedRelations.get(row.serverId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.serverId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as ServerPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
}
for (const [serverId, relation] of groupedRelations) {
relation.tags = tagRows
.filter((row) => row.serverId === serverId)
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
.map((row) => row.value);
relation.channels.sort(
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
return groupedRelations;
}
export function relationRecordToServerPayload(
row: Pick<ServerPayload, 'slowModeInterval'>,
relations: ServerRelationRecord
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
return {
tags: relations.tags,
channels: relations.channels,
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions,
slowModeInterval: row.slowModeInterval ?? 0
};
}

View File

@@ -28,70 +28,16 @@ export interface AuthUserPayload {
createdAt: number; createdAt: number;
} }
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type ServerPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: ServerPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface ServerPayload { export interface ServerPayload {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
ownerId: string; ownerId: string;
ownerPublicKey: string; ownerPublicKey: string;
hasPassword?: boolean;
passwordHash?: string | null;
isPrivate: boolean; isPrivate: boolean;
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
slowModeInterval?: number;
tags: string[]; tags: string[];
channels: ServerChannelPayload[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
createdAt: number; createdAt: number;
lastSeen: number; lastSeen: number;
} }

View File

@@ -4,15 +4,7 @@ import { DataSource } from 'typeorm';
import { import {
AuthUserEntity, AuthUserEntity,
ServerEntity, ServerEntity,
ServerTagEntity, JoinRequestEntity
ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../entities'; } from '../entities';
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
@@ -59,15 +51,7 @@ export async function initDatabase(): Promise<void> {
entities: [ entities: [
AuthUserEntity, AuthUserEntity,
ServerEntity, ServerEntity,
ServerTagEntity, JoinRequestEntity
ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: false, synchronize: false,

View File

@@ -1,35 +0,0 @@
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;
}

View File

@@ -1,23 +0,0 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_channels')
export class ServerChannelEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -1,26 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_channel_permissions')
export class ServerChannelPermissionEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -21,9 +21,6 @@ export class ServerEntity {
@Column('text') @Column('text')
ownerPublicKey!: string; ownerPublicKey!: string;
@Column('text', { nullable: true })
passwordHash!: string | null;
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
isPrivate!: number; isPrivate!: number;
@@ -33,8 +30,8 @@ export class ServerEntity {
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
currentUsers!: number; currentUsers!: number;
@Column('integer', { default: 0 }) @Column('text', { default: '[]' })
slowModeInterval!: number; tags!: string;
@Column('integer') @Column('integer')
createdAt!: number; createdAt!: number;

View File

@@ -1,29 +0,0 @@
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;
}

View File

@@ -1,26 +0,0 @@
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;
}

View File

@@ -1,59 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_roles')
export class ServerRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -1,17 +0,0 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_tags')
export class ServerTagEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('integer')
position!: number;
@Column('text')
value!: string;
}

View File

@@ -1,20 +0,0 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_user_roles')
export class ServerUserRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
userId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -1,11 +1,3 @@
export { AuthUserEntity } from './AuthUserEntity'; export { AuthUserEntity } from './AuthUserEntity';
export { ServerEntity } from './ServerEntity'; export { ServerEntity } from './ServerEntity';
export { ServerTagEntity } from './ServerTagEntity';
export { ServerChannelEntity } from './ServerChannelEntity';
export { ServerRoleEntity } from './ServerRoleEntity';
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
export { JoinRequestEntity } from './JoinRequestEntity'; export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity';

View File

@@ -14,39 +14,23 @@ import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app'; import { createApp } from './app';
import { import {
ensureVariablesConfig, ensureVariablesConfig,
getServerHost,
getVariablesConfigPath, getVariablesConfigPath,
getServerPort, hasKlipyApiKey
getServerProtocol,
ServerHttpProtocol
} from './config/variables'; } from './config/variables';
import { setupWebSocket } from './websocket'; import { setupWebSocket } from './websocket';
function formatHostForUrl(host: string): string { const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
if (host.startsWith('[') || !host.includes(':')) { const PORT = process.env.PORT || 3001;
return host;
}
return `[${host}]`; function buildServer(app: ReturnType<typeof createApp>) {
} if (USE_SSL) {
function getDisplayHost(serverHost: string | undefined): string {
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
return 'localhost';
}
return serverHost;
}
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
if (serverProtocol === 'https') {
const certDir = resolveCertificateDirectory(); const certDir = resolveCertificateDirectory();
const certFile = path.join(certDir, 'localhost.crt'); const certFile = path.join(certDir, 'localhost.crt');
const keyFile = path.join(certDir, 'localhost.key'); const keyFile = path.join(certDir, 'localhost.key');
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.error(`HTTPS is enabled but certs were not found in ${certDir}`); console.error(`SSL=true but certs not found in ${certDir}`);
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".'); console.error('Run ./generate-cert.sh first.');
process.exit(1); process.exit(1);
} }
@@ -60,31 +44,17 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
} }
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const variablesConfig = ensureVariablesConfig(); ensureVariablesConfig();
const serverProtocol = getServerProtocol();
const serverPort = getServerPort();
const serverHost = getServerHost();
const bindHostLabel = serverHost || 'default interface';
console.log('[Config] Variables loaded from:', getVariablesConfigPath()); console.log('[Config] Variables loaded from:', getVariablesConfigPath());
if ( if (!hasKlipyApiKey()) {
variablesConfig.serverProtocol !== serverProtocol
|| variablesConfig.serverPort !== serverPort
) {
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
} else {
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
}
if (!variablesConfig.klipyApiKey) {
console.log('[KLIPY] API key not configured. GIF search is disabled.'); console.log('[KLIPY] API key not configured. GIF search is disabled.');
} }
await initDatabase(); await initDatabase();
const app = createApp(); const app = createApp();
const server = buildServer(app, serverProtocol); const server = buildServer(app);
setupWebSocket(server); setupWebSocket(server);
@@ -94,29 +64,14 @@ async function bootstrap(): Promise<void> {
.catch(err => console.error('Failed to clean up stale join requests:', err)); .catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000); }, 60 * 1000);
const onListening = () => { server.listen(PORT, () => {
const displayHost = formatHostForUrl(getDisplayHost(serverHost)); const proto = USE_SSL ? 'https' : 'http';
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws'; const wsProto = USE_SSL ? '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(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`); console.log(` REST API: ${proto}://localhost:${PORT}/api`);
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`); console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
});
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
}
};
if (serverHost) {
server.listen(serverPort, serverHost, onListening);
} else {
server.listen(serverPort, onListening);
}
} }
bootstrap().catch((err) => { bootstrap().catch((err) => {

View File

@@ -25,7 +25,6 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0, "maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0, "currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]', "tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL, "createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL "lastSeen" INTEGER NOT NULL
) )

View File

@@ -1,56 +0,0 @@
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"`);
}
}

View File

@@ -1,13 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -1,119 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -1,142 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
tags: string | null;
channels: string | null;
};
type LegacyServerChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: 'text' | 'voice', name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeServerTags(raw: string | null): string[] {
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
}
function normalizeServerChannels(raw: string | null) {
const channels = parseArray<LegacyServerChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
name = 'NormalizeServerArrays1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_tags" (
"serverId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "position")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channels" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
[row.id, position, tag]
);
}
for (const channel of normalizeServerChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"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,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL,
"passwordHash" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
}
}

View File

@@ -1,196 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
name: string;
description: string | null;
ownerId: string;
ownerPublicKey: string;
passwordHash: string | null;
isPrivate: number;
maxUsers: number;
currentUsers: number;
createdAt: number;
lastSeen: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function buildDefaultServerRoles() {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'allow',
manageIcon: 'allow',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
name = 'ServerRoleAccessControl1000000000005';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_roles" (
"serverId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("serverId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_user_roles" (
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("serverId", "userId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
const servers = await queryRunner.query(`
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
FROM "servers"
`) as LegacyServerRow[];
for (const server of servers) {
for (const role of buildDefaultServerRoles()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
server.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"passwordHash" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
}
}

View File

@@ -1,15 +1,3 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
export const serverMigrations = [ export const serverMigrations = [InitialSchema1000000000000];
InitialSchema1000000000000,
ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005
];

View File

@@ -5,7 +5,6 @@ import proxyRouter from './proxy';
import usersRouter from './users'; import usersRouter from './users';
import serversRouter from './servers'; import serversRouter from './servers';
import joinRequestsRouter from './join-requests'; import joinRequestsRouter from './join-requests';
import { invitesApiRouter, invitePageRouter } from './invites';
export function registerRoutes(app: Express): void { export function registerRoutes(app: Express): void {
app.use('/api', healthRouter); app.use('/api', healthRouter);
@@ -13,7 +12,5 @@ export function registerRoutes(app: Express): void {
app.use('/api', proxyRouter); app.use('/api', proxyRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);
app.use('/api/invites', invitesApiRouter);
app.use('/api/requests', joinRequestsRouter); app.use('/api/requests', joinRequestsRouter);
app.use('/invite', invitePageRouter);
} }

View File

@@ -1,57 +0,0 @@
import { Request } from 'express';
function buildOrigin(protocol: string, host: string): string {
return `${protocol}://${host}`.replace(/\/+$/, '');
}
function originFromUrl(url: URL): string {
return buildOrigin(url.protocol.replace(':', ''), url.host);
}
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);
if (url.hostname === 'signal.toju.app' && !url.port) {
return 'https://web.toju.app';
}
if (url.hostname.startsWith('signal.')) {
url.hostname = url.hostname.replace(/^signal\./, 'web.');
if (url.port === '3001') {
url.port = '4200';
}
return originFromUrl(url);
}
if (url.port === '3001') {
url.port = '4200';
return originFromUrl(url);
}
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)}`;
}

View File

@@ -1,330 +0,0 @@
import { Router } from 'express';
import { getUserById } from '../cqrs';
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 = 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 = 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()
}));
});

View File

@@ -6,7 +6,6 @@ import {
updateJoinRequestStatus updateJoinRequestStatus
} from '../cqrs'; } from '../cqrs';
import { notifyUser } from '../websocket/broadcast'; import { notifyUser } from '../websocket/broadcast';
import { resolveServerPermission } from '../services/server-permissions.service';
const router = Router(); const router = Router();
@@ -20,7 +19,7 @@ router.put('/:id', async (req, res) => {
const server = await getServerById(request.serverId); const server = await getServerById(request.serverId);
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer')) if (!server || server.ownerId !== ownerId)
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']); await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);

View File

@@ -1,128 +1,29 @@
import { Response, Router } from 'express'; import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ServerChannelPayload, ServerPayload } from '../cqrs/types'; import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
import { import {
getAllPublicServers, getAllPublicServers,
getServerById, getServerById,
getUserById, getUserById,
upsertServer, upsertServer,
deleteServer, deleteServer,
createJoinRequest,
getPendingRequestsForServer getPendingRequestsForServer
} from '../cqrs'; } from '../cqrs';
import { import { notifyServerOwner } from '../websocket/broadcast';
banServerUser,
buildSignalingUrl,
createServerInvite,
joinServerWithAccess,
leaveServerUser,
passwordHashForInput,
ServerAccessError,
kickServerUser,
ensureServerMembership,
unbanServerUser
} from '../services/server-access.service';
import {
buildAppInviteUrl,
buildBrowserInviteUrl,
buildInviteUrl,
getRequestOrigin
} from './invite-utils';
import {
canManageServerUpdate,
canModerateServerMember,
resolveServerPermission
} from '../services/server-permissions.service';
const router = Router(); const router = Router();
function channelNameKey(type: ServerChannelPayload['type'], name: string): string { async function enrichServer(server: ServerPayload) {
return `${type}:${name.toLocaleLowerCase()}`;
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, channel] of value.entries()) {
if (!channel || typeof channel !== 'object') {
continue;
}
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
continue;
}
seen.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId); const owner = await getUserById(server.ownerId);
const { passwordHash, ...publicServer } = server;
return { return {
...publicServer, ...server,
hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName, ownerName: owner?.displayName,
sourceUrl,
userCount: server.currentUsers 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) => { router.get('/', async (req, res) => {
const { q, tags, limit = 20, offset = 0 } = req.query; const { q, tags, limit = 20, offset = 0 } = req.query;
@@ -153,238 +54,45 @@ router.get('/', async (req, res) => {
}); });
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
id: clientId,
name,
description,
ownerId,
ownerPublicKey,
isPrivate,
maxUsers,
password,
tags,
channels
} = req.body;
if (!name || !ownerId || !ownerPublicKey) if (!name || !ownerId || !ownerPublicKey)
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
const passwordHash = passwordHashForInput(password);
const server: ServerPayload = { const server: ServerPayload = {
id: clientId || uuidv4(), id: clientId || uuidv4(),
name, name,
description, description,
ownerId, ownerId,
ownerPublicKey, ownerPublicKey,
hasPassword: !!passwordHash,
passwordHash,
isPrivate: isPrivate ?? false, isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0, maxUsers: maxUsers ?? 0,
currentUsers: 0, currentUsers: 0,
tags: tags ?? [], tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(), createdAt: Date.now(),
lastSeen: Date.now() lastSeen: Date.now()
}; };
await upsertServer(server); await upsertServer(server);
await ensureServerMembership(server.id, ownerId); res.status(201).json(server);
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
}); });
router.put('/:id', async (req, res) => { router.put('/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { const { currentOwnerId, ...updates } = req.body;
currentOwnerId,
actingRole,
password,
hasPassword: _ignoredHasPassword,
passwordHash: _ignoredPasswordHash,
channels,
...updates
} = req.body;
const existing = await getServerById(id); const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId; const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
if (!existing) if (!existing)
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
if (!authenticatedOwnerId) { if (existing.ownerId !== authenticatedOwnerId)
return res.status(400).json({ error: 'Missing currentOwnerId' });
}
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
...updates,
channels,
password,
actingRole
})) {
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
}
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password'); const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
const server: ServerPayload = {
...existing,
...updates,
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
hasPassword: !!nextPasswordHash,
passwordHash: nextPasswordHash,
lastSeen: Date.now()
};
await upsertServer(server); await upsertServer(server);
res.json(await enrichServer(server, getRequestOrigin(req))); res.json(server);
});
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, 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
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, 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
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, 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 (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
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/leave', async (req, res) => {
const { id: serverId } = req.params;
const { userId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
}
await leaveServerUser(serverId, String(userId));
res.json({ ok: true });
}); });
router.post('/:id/heartbeat', async (req, res) => { router.post('/:id/heartbeat', async (req, res) => {
@@ -420,6 +128,32 @@ router.delete('/:id', async (req, res) => {
res.json({ ok: true }); 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) => { router.get('/:id/requests', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { ownerId } = req.query; const { ownerId } = req.query;
@@ -436,15 +170,4 @@ router.get('/:id/requests', async (req, res) => {
res.json({ requests }); 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; export default router;

View File

@@ -1,400 +0,0 @@
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 { loadServerRelationsMap } from '../cqrs/relations';
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);
}
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
return rowToServer(server, relationsByServerId.get(server.id));
}
function normalizePassword(password?: string | null): string | null {
const normalized = password?.trim() ?? '';
return normalized.length > 0 ? normalized : null;
}
function isServerOwner(server: ServerEntity, userId: string): boolean {
return server.ownerId === userId;
}
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: ServerPayload } | 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: await toServerPayload(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 (isServerOwner(server, options.userId)) {
const existingMembership = await findServerMembership(server.id, options.userId);
await ensureServerMembership(server.id, options.userId);
return {
joinedBefore: !!existingMembership,
server: await toServerPayload(server),
via: 'membership'
};
}
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: await toServerPayload(server),
via: 'invite'
};
}
const membership = await findServerMembership(server.id, options.userId);
if (membership) {
await ensureServerMembership(server.id, options.userId);
return {
joinedBefore: true,
server: await toServerPayload(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: await toServerPayload(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: await toServerPayload(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' };
}
if (isServerOwner(server, userId)) {
await ensureServerMembership(serverId, userId);
return { allowed: true };
}
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 leaveServerUser(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 });
}
}

View File

@@ -1,191 +0,0 @@
import type {
AccessRolePayload,
PermissionStatePayload,
RoleAssignmentPayload,
ServerPayload,
ServerPermissionKeyPayload
} from '../cqrs/types';
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone'
} as const;
interface ServerIdentity {
userId: string;
oderId?: string;
}
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
return normalizeServerRoles(server.roles);
}
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
}
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
return assignment.userId === identity.userId
|| assignment.oderId === identity.userId
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
}
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
return assignment?.roleIds ?? [];
}
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
}
function resolveRolePermissionState(
roles: readonly AccessRolePayload[],
assignedRoleIds: readonly string[],
permission: ServerPermissionKeyPayload
): PermissionStatePayload {
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
.filter((role): role is AccessRolePayload => !!role)
.sort(compareRolePosition);
let state: PermissionStatePayload = 'inherit';
for (const role of effectiveRoles) {
const nextState = role.permissions?.[permission] ?? 'inherit';
if (nextState !== 'inherit') {
state = nextState;
}
}
return state;
}
function resolveHighestRole(
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
identity: ServerIdentity
): AccessRolePayload | null {
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const assignedRoles = assignedRoleIds
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is AccessRolePayload => !!role)
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
}
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
return server.ownerId === actorUserId;
}
export function resolveServerPermission(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
permission: ServerPermissionKeyPayload,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, {
userId: actorUserId,
oderId: actorOderId
});
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
}
export function canManageServerUpdate(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
updates: Record<string, unknown>,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
return false;
}
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
if (
Array.isArray(updates['roles'])
|| Array.isArray(updates['roleAssignments'])
|| Array.isArray(updates['channelPermissions'])
) {
requiredPermissions.add('manageRoles');
}
if (Array.isArray(updates['channels'])) {
requiredPermissions.add('manageChannels');
}
if (typeof updates['icon'] === 'string') {
requiredPermissions.add('manageIcon');
}
if (
typeof updates['name'] === 'string'
|| typeof updates['description'] === 'string'
|| typeof updates['isPrivate'] === 'boolean'
|| typeof updates['maxUsers'] === 'number'
|| typeof updates['password'] === 'string'
|| typeof updates['passwordHash'] === 'string'
|| typeof updates['slowModeInterval'] === 'number'
) {
requiredPermissions.add('manageServer');
}
return Array.from(requiredPermissions).every((permission) =>
resolveServerPermission(server, actorUserId, permission, actorOderId)
);
}
export function canModerateServerMember(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
targetUserId: string,
permission: 'kickMembers' | 'banMembers' | 'manageBans',
actorOderId?: string,
targetOderId?: string
): boolean {
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
return false;
}
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
return false;
}
if (isServerOwner(server, actorUserId)) {
return true;
}
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
return false;
}
const actorRole = resolveHighestRole(server, {
userId: actorUserId,
oderId: actorOderId
});
const targetRole = resolveHighestRole(server, {
userId: targetUserId,
oderId: targetOderId
});
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
}

View File

@@ -1,59 +1,36 @@
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types'; import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId } from './broadcast'; import { broadcastToServer, findUserByOderId } from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service';
interface WsMessage { interface WsMessage {
[key: string]: unknown; [key: string]: unknown;
type: string; type: string;
} }
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || fallback;
}
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = Array.from(connectedUsers.values()) const users = Array.from(connectedUsers.values())
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId) .filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) })); .map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
user.oderId = String(message['oderId'] || connectionId); user.oderId = String(message['oderId'] || connectionId);
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = String(message['displayName'] || 'Anonymous');
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const sid = String(message['serverId']); 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); const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid); user.serverIds.add(sid);
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`); console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
sendServerUsers(user, sid); sendServerUsers(user, sid);
@@ -61,7 +38,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: user.displayName ?? 'Anonymous',
serverId: sid serverId: sid
}, user.oderId); }, user.oderId);
} }
@@ -72,7 +49,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
sendServerUsers(user, viewSid); sendServerUsers(user, viewSid);
} }
@@ -93,9 +70,8 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
broadcastToServer(leaveSid, { broadcastToServer(leaveSid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: user.displayName ?? 'Anonymous',
serverId: leaveSid, serverId: leaveSid
serverIds: Array.from(user.serverIds)
}, user.oderId); }, user.oderId);
} }
@@ -134,22 +110,18 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, { broadcastToServer(typingSid, {
type: 'user_typing', type: 'user_typing',
serverId: typingSid, serverId: typingSid,
channelId,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName displayName: user.displayName
}, user.oderId); }, user.oderId);
} }
} }
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> { export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (!user) if (!user)
@@ -161,7 +133,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break; break;
case 'join_server': case 'join_server':
await handleJoinServer(user, message, connectionId); handleJoinServer(user, message, connectionId);
break; break;
case 'view_server': case 'view_server':

View File

@@ -9,87 +9,39 @@ import { connectedUsers } from './state';
import { broadcastToServer } from './broadcast'; import { broadcastToServer } from './broadcast';
import { handleWebSocketMessage } from './handler'; import { handleWebSocketMessage } from './handler';
/** How often to ping all connected clients (ms). */
const PING_INTERVAL_MS = 30_000;
/** Maximum time a client can go without a pong before we consider it dead (ms). */
const PONG_TIMEOUT_MS = 45_000;
function removeDeadConnection(connectionId: string): void {
const user = connectedUsers.get(connectionId);
if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
user.serverIds.forEach((sid) => {
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid,
serverIds: []
}, user.oderId);
});
try {
user.ws.terminate();
} catch {
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
}
}
connectedUsers.delete(connectionId);
}
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void { export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
// Periodically ping all clients and reap dead connections
const pingInterval = setInterval(() => {
const now = Date.now();
connectedUsers.forEach((user, connectionId) => {
if (now - user.lastPong > PONG_TIMEOUT_MS) {
removeDeadConnection(connectionId);
return;
}
if (user.ws.readyState === WebSocket.OPEN) {
try {
user.ws.ping();
} catch {
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
}
}
});
}, PING_INTERVAL_MS);
wss.on('close', () => clearInterval(pingInterval));
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
const connectionId = uuidv4(); const connectionId = uuidv4();
const now = Date.now();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now }); connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
ws.on('pong', () => { ws.on('message', (data) => {
const user = connectedUsers.get(connectionId);
if (user) {
user.lastPong = Date.now();
}
});
ws.on('message', async (data) => {
try { try {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.toString());
await handleWebSocketMessage(connectionId, message); handleWebSocketMessage(connectionId, message);
} catch (err) { } catch (err) {
console.error('Invalid WebSocket message:', err); console.error('Invalid WebSocket message:', err);
} }
}); });
ws.on('close', () => { ws.on('close', () => {
removeDeadConnection(connectionId); 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() })); ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));

View File

@@ -6,6 +6,4 @@ export interface ConnectedUser {
serverIds: Set<string>; serverIds: Set<string>;
viewedServerId?: string; viewedServerId?: string;
displayName?: string; displayName?: string;
/** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number;
} }

View File

@@ -13,7 +13,6 @@ import { routes } from './app.routes';
import { messagesReducer } from './store/messages/messages.reducer'; import { messagesReducer } from './store/messages/messages.reducer';
import { usersReducer } from './store/users/users.reducer'; import { usersReducer } from './store/users/users.reducer';
import { roomsReducer } from './store/rooms/rooms.reducer'; import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
@@ -33,7 +32,6 @@ export const appConfig: ApplicationConfig = {
rooms: roomsReducer rooms: roomsReducer
}), }),
provideEffects([ provideEffects([
NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UsersEffects, UsersEffects,

101
src/app/app.html Normal file
View File

@@ -0,0 +1,101 @@
<div class="h-screen bg-background text-foreground flex">
<!-- Global left servers rail always visible -->
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
<app-servers-rail class="h-full" />
</aside>
<main class="flex-1 min-w-0 relative overflow-hidden">
<!-- Custom draggable title bar -->
<app-title-bar />
@if (desktopUpdateState().restartRequired) {
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
<p class="mt-1 text-sm text-muted-foreground">
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
(click)="openUpdatesSettings()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Update settings
</button>
<button
type="button"
(click)="restartToApplyUpdate()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart now
</button>
</div>
</div>
</div>
</div>
}
<!-- Content area fills below the title bar without global scroll -->
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
<router-outlet />
</div>
</main>
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
<app-floating-voice-controls />
</div>
@if (desktopUpdateState().serverBlocked) {
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
<p class="mt-3 text-sm text-muted-foreground">
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
</p>
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
(click)="refreshDesktopUpdateContext()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
<button
type="button"
(click)="openNetworkSettings()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Open network settings
</button>
</div>
</div>
</div>
}
<!-- Unified Settings Modal -->
<app-settings-modal />
<!-- Shared Screen Share Source Picker -->
<app-screen-share-source-picker />
<!-- Shared Debug Console -->
<app-debug-console [showLauncher]="false" />

View File

@@ -10,22 +10,17 @@ export const routes: Routes = [
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent) import('./features/auth/login/login.component').then((module) => module.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent) import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
},
{
path: 'invite/:inviteId',
loadComponent: () =>
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
}, },
{ {
path: 'search', path: 'search',
loadComponent: () => loadComponent: () =>
import('./domains/server-directory/feature/server-search/server-search.component').then( import('./features/server-search/server-search.component').then(
(module) => module.ServerSearchComponent (module) => module.ServerSearchComponent
) )
}, },

134
src/app/app.ts Normal file
View File

@@ -0,0 +1,134 @@
/* eslint-disable @angular-eslint/component-class-suffix */
import {
Component,
OnInit,
inject,
HostListener
} from '@angular/core';
import {
Router,
RouterOutlet,
NavigationEnd
} from '@angular/router';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { DatabaseService } from './core/services/database.service';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryService } from './core/services/server-directory.service';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service';
import { ExternalLinkService } from './core/services/external-link.service';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import {
ROOM_URL_PATTERN,
STORAGE_KEY_CURRENT_USER_ID,
STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants';
@Component({
selector: 'app-root',
imports: [
CommonModule,
RouterOutlet,
ServersRailComponent,
TitleBarComponent,
FloatingVoiceControlsComponent,
SettingsModalComponent,
DebugConsoleComponent,
ScreenShareSourcePickerComponent
],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App implements OnInit {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
desktopUpdateState = this.desktopUpdates.state;
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryService);
private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService);
@HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void {
this.externalLinks.handleClick(evt);
}
async ngOnInit(): Promise<void> {
void this.desktopUpdates.initialize();
await this.databaseService.initialize();
try {
const apiBase = this.servers.getApiBaseUrl();
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') {
this.router.navigate(['/login']).catch(() => {});
}
} else {
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
if (last && typeof last === 'string') {
const current = this.router.url;
if (current === '/' || current === '/search') {
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
}
}
}
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
this.voiceSession.checkCurrentRoute(currentRoomId);
}
});
}
openNetworkSettings(): void {
this.settingsModal.open('network');
}
openUpdatesSettings(): void {
this.settingsModal.open('updates');
}
async refreshDesktopUpdateContext(): Promise<void> {
await this.desktopUpdates.refreshServerContext();
}
async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate();
}
}

View File

@@ -1,12 +1,8 @@
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
export const STORAGE_KEY_GENERAL_SETTINGS = 'metoyou_general_settings'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
export const STORAGE_KEY_LAST_VIEWED_CHAT = 'metoyou_lastViewedChat';
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
export const STORE_DEVTOOLS_MAX_AGE = 25; export const STORE_DEVTOOLS_MAX_AGE = 25;

Some files were not shown because too many files have changed in this diff Show More