Compare commits
52 Commits
v1.0.74
...
b10004e154
| Author | SHA1 | Date | |
|---|---|---|---|
| b10004e154 | |||
| f33440a827 | |||
| 52912327ae | |||
| 7a0664b3c4 | |||
| cea3dccef1 | |||
| c8bb82feb5 | |||
| 3fb5515c3a | |||
| a6bdac1a25 | |||
| db7e683504 | |||
| 98ed8eeb68 | |||
| 39b85e2e3a | |||
| 58e338246f | |||
| 0b9a9f311e | |||
| 6800c73292 | |||
| ef1182d46f | |||
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 | |||
| ae0ee8fac7 | |||
| 37cac95b38 | |||
| 314a26325f | |||
| 5d7e045764 | |||
| bbb6deb0a2 | |||
| 65b9419869 | |||
| fed270d28d | |||
| 8b6578da3c | |||
| 851d6ae759 | |||
| 1e833ec7f2 | |||
| 64e34ad586 | |||
| e3b23247a9 | |||
| 42ac712571 | |||
| b7d4bf20e3 | |||
| 727059fb52 | |||
| 83694570e3 | |||
| 109402cdd6 | |||
| eb23fd71ec | |||
| 11917f3412 | |||
| 8162e0444a | |||
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 |
63
.agents/skills/caveman/SKILL.md
Normal file
63
.agents/skills/caveman/SKILL.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: caveman
|
||||||
|
description: >
|
||||||
|
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||||
|
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||||
|
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||||
|
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||||
|
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
|
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||||
|
|
||||||
|
Pattern: `[thing] [action] [reason]. [next step].`
|
||||||
|
|
||||||
|
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||||
|
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||||
|
|
||||||
|
## Intensity
|
||||||
|
|
||||||
|
| Level | What change |
|
||||||
|
|-------|------------|
|
||||||
|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||||
|
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||||
|
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||||
|
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||||
|
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||||
|
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||||
|
|
||||||
|
Example — "Why React component re-render?"
|
||||||
|
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||||
|
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||||
|
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||||
|
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||||
|
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||||
|
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||||
|
|
||||||
|
Example — "Explain database connection pooling."
|
||||||
|
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||||
|
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||||
|
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||||
|
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||||
|
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||||
|
|
||||||
|
## Auto-Clarity
|
||||||
|
|
||||||
|
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
|
||||||
|
|
||||||
|
Example — destructive op:
|
||||||
|
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||||
|
> ```sql
|
||||||
|
> DROP TABLE users;
|
||||||
|
> ```
|
||||||
|
> Caveman resume. Verify backup exist first.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||||
282
.agents/skills/playwright-e2e/SKILL.md
Normal file
282
.agents/skills/playwright-e2e/SKILL.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
---
|
||||||
|
name: playwright-e2e
|
||||||
|
description: >
|
||||||
|
Write and run Playwright E2E tests for MetoYou (Angular chat + WebRTC voice/video app).
|
||||||
|
Handles multi-browser-context WebRTC voice testing, fake media devices, signaling validation,
|
||||||
|
audio channel verification, and UI state assertions. Use when: "E2E test", "Playwright",
|
||||||
|
"end-to-end", "browser test", "test voice", "test call", "test WebRTC", "test chat",
|
||||||
|
"test login", "test video", "test screen share", "integration test", "multi-client test".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playwright E2E Testing — MetoYou
|
||||||
|
|
||||||
|
## Step 1 — Check Project Setup
|
||||||
|
|
||||||
|
Before writing any test, verify the Playwright infrastructure exists:
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/ # Test root (lives at repo root)
|
||||||
|
├── playwright.config.ts # Config
|
||||||
|
├── fixtures/ # Custom fixtures (multi-client, auth, etc.)
|
||||||
|
├── pages/ # Page Object Models
|
||||||
|
├── tests/ # Test specs
|
||||||
|
│ ├── auth/
|
||||||
|
│ ├── chat/
|
||||||
|
│ ├── voice/
|
||||||
|
│ └── settings/
|
||||||
|
└── helpers/ # Shared utilities (WebRTC introspection, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
If missing, scaffold it. See [reference/project-setup.md](./reference/project-setup.md).
|
||||||
|
|
||||||
|
## Step 2 — Identify Test Category
|
||||||
|
|
||||||
|
| Request | Category | Key Patterns |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| Login, register, invite | **Auth** | Single browser context, form interaction |
|
||||||
|
| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync |
|
||||||
|
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
|
||||||
|
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
|
||||||
|
| Screen share | **Screen Share** | Multi-client, display media mocking |
|
||||||
|
| Settings, themes | **Settings** | Single client, preference persistence |
|
||||||
|
|
||||||
|
For **Voice/WebRTC** and **Multi-client** tests, read [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) immediately.
|
||||||
|
|
||||||
|
## Step 3 — Core Conventions
|
||||||
|
|
||||||
|
### Config Essentials
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// e2e/playwright.config.ts
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 60_000, // WebRTC needs longer timeouts
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1, // Sequential — shared server state
|
||||||
|
reporter: [['html'], ['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4200',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'on-first-retry',
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
// Feed a specific audio file as fake mic input:
|
||||||
|
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'cd server && npm run dev',
|
||||||
|
port: 3001,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'cd toju-app && npx ng serve',
|
||||||
|
port: 4200,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selector Strategy
|
||||||
|
|
||||||
|
Use in this order — stop at the first that works:
|
||||||
|
|
||||||
|
1. `getByRole('button', { name: 'Mute' })` — accessible, resilient
|
||||||
|
2. `getByLabel('Email')` — form fields
|
||||||
|
3. `getByPlaceholder('Enter email')` — when label missing
|
||||||
|
4. `getByText('Welcome')` — visible text
|
||||||
|
5. `getByTestId('voice-controls')` — last resort, needs `data-testid`
|
||||||
|
6. `locator('app-voice-controls')` — Angular component selectors (acceptable in this project)
|
||||||
|
|
||||||
|
Angular component selectors (`app-*`) are stable in this project and acceptable as locators when semantic selectors are not feasible.
|
||||||
|
|
||||||
|
### Assertions — Always Web-First
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Auto-retries until timeout
|
||||||
|
await expect(page.getByRole('heading')).toBeVisible();
|
||||||
|
await expect(page.getByRole('alert')).toHaveText('Saved');
|
||||||
|
await expect(page).toHaveURL(/\/room\//);
|
||||||
|
|
||||||
|
// ❌ No auto-retry — races with DOM
|
||||||
|
const text = await page.textContent('.msg');
|
||||||
|
expect(text).toBe('Saved');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
| ❌ Don't | ✅ Do | Why |
|
||||||
|
|----------|-------|-----|
|
||||||
|
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
|
||||||
|
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
|
||||||
|
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
|
||||||
|
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
|
||||||
|
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
|
||||||
|
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '../fixtures/base';
|
||||||
|
|
||||||
|
test.describe('Feature Name', () => {
|
||||||
|
test('should do specific thing', async ({ page }) => {
|
||||||
|
await test.step('Navigate to page', async () => {
|
||||||
|
await page.goto('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Fill form', async () => {
|
||||||
|
await page.getByLabel('Email').fill('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify result', async () => {
|
||||||
|
await expect(page).toHaveURL(/\/search/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `test.step()` for readability in complex flows.
|
||||||
|
|
||||||
|
## Step 4 — Page Object Models
|
||||||
|
|
||||||
|
Use POM for any test file with more than 2 tests. Match the app's route structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// e2e/pages/login.page.ts
|
||||||
|
import { type Page, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
export class LoginPage {
|
||||||
|
readonly emailInput: Locator;
|
||||||
|
readonly passwordInput: Locator;
|
||||||
|
readonly submitButton: Locator;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.emailInput = page.getByLabel('Email');
|
||||||
|
this.passwordInput = page.getByLabel('Password');
|
||||||
|
this.submitButton = page.getByRole('button', { name: /sign in|log in/i });
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
await this.emailInput.fill(email);
|
||||||
|
await this.passwordInput.fill(password);
|
||||||
|
await this.submitButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key pages to model (match `app.routes.ts`):**
|
||||||
|
|
||||||
|
| Route | Page Object | Component |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| `/login` | `LoginPage` | `LoginComponent` |
|
||||||
|
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||||
|
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
||||||
|
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
||||||
|
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
||||||
|
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
||||||
|
|
||||||
|
## Step 5 — MetoYou App Architecture Context
|
||||||
|
|
||||||
|
The agent writing tests MUST understand these domain boundaries:
|
||||||
|
|
||||||
|
### Voice/WebRTC Stack
|
||||||
|
|
||||||
|
| Layer | What It Does | Test Relevance |
|
||||||
|
|-------|-------------|----------------|
|
||||||
|
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
|
||||||
|
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
|
||||||
|
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
|
||||||
|
| `VoicePlaybackService` | Per-peer GainNode (0–200% volume) | Volume level assertions |
|
||||||
|
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
|
||||||
|
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
|
||||||
|
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
|
||||||
|
|
||||||
|
### Voice UI Components
|
||||||
|
|
||||||
|
| Component | Selector | Contains |
|
||||||
|
|-----------|----------|----------|
|
||||||
|
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
|
||||||
|
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
|
||||||
|
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
|
||||||
|
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
|
||||||
|
|
||||||
|
### Voice UI Icons (Lucide)
|
||||||
|
|
||||||
|
| Icon | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `lucideMic` / `lucideMicOff` | Mute toggle |
|
||||||
|
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
|
||||||
|
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
|
||||||
|
| `lucidePhoneOff` | Hang up / disconnect |
|
||||||
|
| `lucideHeadphones` | Deafen state |
|
||||||
|
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
|
||||||
|
|
||||||
|
### Server & Signaling
|
||||||
|
|
||||||
|
- **Signaling server**: Port `3001` (HTTP by default, HTTPS if `SSL=true`)
|
||||||
|
- **Angular dev server**: Port `4200`
|
||||||
|
- **WebSocket signaling**: Upgrades on same port as server
|
||||||
|
- **Protocol**: `identify` → `server_users` → SDP offer/answer → ICE candidates
|
||||||
|
- **PING/PONG**: Every 30s, 45s timeout
|
||||||
|
|
||||||
|
## Step 6 — Validation Workflow
|
||||||
|
|
||||||
|
After generating any test:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. npx playwright install --with-deps chromium # First time only
|
||||||
|
2. npx playwright test --project=chromium # Run tests
|
||||||
|
3. npx playwright test --ui # Interactive debug
|
||||||
|
4. npx playwright show-report # HTML report
|
||||||
|
```
|
||||||
|
|
||||||
|
If the test involves WebRTC, always verify:
|
||||||
|
- Fake media flags are set in config
|
||||||
|
- Timeouts are sufficient (60s+ for connection establishment)
|
||||||
|
- `workers: 1` if tests share server state
|
||||||
|
- Browser permissions granted for microphone/camera
|
||||||
|
|
||||||
|
## Quick Reference — Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test # Run all
|
||||||
|
npx playwright test --ui # Interactive UI
|
||||||
|
npx playwright test --debug # Step-through debugger
|
||||||
|
npx playwright test tests/voice/ # Voice tests only
|
||||||
|
npx playwright test --project=chromium # Single browser
|
||||||
|
npx playwright test -g "voice connects" # By test name
|
||||||
|
npx playwright show-report # HTML report
|
||||||
|
npx playwright codegen http://localhost:4200 # Record test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
| File | When to Read |
|
||||||
|
|------|-------------|
|
||||||
|
| [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) | Voice/video/WebRTC tests, multi-browser contexts, audio validation |
|
||||||
|
| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation |
|
||||||
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal file
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# Multi-Client WebRTC Testing
|
||||||
|
|
||||||
|
This reference covers the hardest E2E testing scenario in MetoYou: verifying that voice/video connections actually work between multiple clients.
|
||||||
|
|
||||||
|
## Core Concept: Multiple Browser Contexts
|
||||||
|
|
||||||
|
Playwright can create multiple **independent** browser contexts within a single test. Each context is an isolated session (separate cookies, storage, WebRTC state). This is how we simulate multiple users.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect, chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
test('two users can voice chat', async () => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
'--use-file-for-fake-audio-capture=e2e/fixtures/test-tone-440hz.wav',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextA = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
});
|
||||||
|
const contextB = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const alice = await contextA.newPage();
|
||||||
|
const bob = await contextB.newPage();
|
||||||
|
|
||||||
|
// ... test logic with alice and bob ...
|
||||||
|
|
||||||
|
await contextA.close();
|
||||||
|
await contextB.close();
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Fixture: Multi-Client
|
||||||
|
|
||||||
|
Create a reusable fixture for multi-client tests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// e2e/fixtures/multi-client.ts
|
||||||
|
import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test';
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
page: Page;
|
||||||
|
context: BrowserContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiClientFixture = {
|
||||||
|
createClient: () => Promise<Client>;
|
||||||
|
browser: Browser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<MultiClientFixture>({
|
||||||
|
browser: async ({}, use) => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await use(browser);
|
||||||
|
await browser.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
createClient: async ({ browser }, use) => {
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
const factory = async (): Promise<Client> => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
baseURL: 'http://localhost:4200',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
clients.push({ page, context });
|
||||||
|
return { page, context };
|
||||||
|
};
|
||||||
|
|
||||||
|
await use(factory);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
for (const client of clients) {
|
||||||
|
await client.context.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '../fixtures/multi-client';
|
||||||
|
|
||||||
|
test('voice call connects between two users', async ({ createClient }) => {
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
// Login both users
|
||||||
|
await alice.page.goto('/login');
|
||||||
|
await bob.page.goto('/login');
|
||||||
|
// ... login flows ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fake Media Devices
|
||||||
|
|
||||||
|
Chromium's fake device flags are essential for headless WebRTC testing:
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `--use-fake-device-for-media-stream` | Provides fake mic/camera devices (no real hardware needed) |
|
||||||
|
| `--use-fake-ui-for-media-stream` | Auto-grants device permission prompts |
|
||||||
|
| `--use-file-for-fake-audio-capture=<path>` | Feeds a WAV/WebM file as fake mic input |
|
||||||
|
| `--use-file-for-fake-video-capture=<path>` | Feeds a Y4M/MJPEG file as fake video input |
|
||||||
|
|
||||||
|
### Test Audio File
|
||||||
|
|
||||||
|
Create a 440Hz sine wave test tone for audio verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a 5-second 440Hz mono WAV test tone
|
||||||
|
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Playwright's built-in fake device which generates a test pattern (beep) automatically when `--use-fake-device-for-media-stream` is set without specifying a file.
|
||||||
|
|
||||||
|
## WebRTC Connection Introspection
|
||||||
|
|
||||||
|
### Checking RTCPeerConnection State
|
||||||
|
|
||||||
|
Inject JavaScript to inspect WebRTC internals via `page.evaluate()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// e2e/helpers/webrtc-helpers.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all RTCPeerConnection instances and their states.
|
||||||
|
* Requires the app to expose connections (or use a monkey-patch approach).
|
||||||
|
*/
|
||||||
|
export async function getPeerConnectionStates(page: Page): Promise<Array<{
|
||||||
|
connectionState: string;
|
||||||
|
iceConnectionState: string;
|
||||||
|
signalingState: string;
|
||||||
|
}>> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
// Approach 1: Use chrome://webrtc-internals equivalent
|
||||||
|
// Approach 2: Monkey-patch RTCPeerConnection (install before app loads)
|
||||||
|
// Approach 3: Expose from app (preferred for this project)
|
||||||
|
|
||||||
|
// MetoYou exposes via Angular — access the injector
|
||||||
|
const appRef = (window as any).ng?.getComponent(document.querySelector('app-root'));
|
||||||
|
// This needs adaptation based on actual exposure method
|
||||||
|
|
||||||
|
// Fallback: Use performance.getEntriesByType or WebRTC stats
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for at least one peer connection to reach 'connected' state.
|
||||||
|
*/
|
||||||
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
// Check if any RTCPeerConnection reached 'connected'
|
||||||
|
return (window as any).__rtcConnections?.some(
|
||||||
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
|
) ?? false;
|
||||||
|
}, { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebRTC stats for audio tracks (inbound/outbound).
|
||||||
|
*/
|
||||||
|
export async function getAudioStats(page: Page): Promise<{
|
||||||
|
outbound: { bytesSent: number; packetsSent: number } | null;
|
||||||
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
|
}> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
if (!connections?.length) return { outbound: null, inbound: null };
|
||||||
|
|
||||||
|
const pc = connections[0];
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
|
let outbound: any = null;
|
||||||
|
let inbound: any = null;
|
||||||
|
|
||||||
|
stats.forEach((report) => {
|
||||||
|
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
|
||||||
|
outbound = { bytesSent: report.bytesSent, packetsSent: report.packetsSent };
|
||||||
|
}
|
||||||
|
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
|
||||||
|
inbound = { bytesReceived: report.bytesReceived, packetsReceived: report.packetsReceived };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { outbound, inbound };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RTCPeerConnection Monkey-Patch
|
||||||
|
|
||||||
|
To track all peer connections created by the app, inject a monkey-patch **before** navigation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Install RTCPeerConnection tracking on a page BEFORE navigating.
|
||||||
|
* Call this immediately after page creation, before any goto().
|
||||||
|
*/
|
||||||
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
(window as any).__rtcConnections = connections;
|
||||||
|
|
||||||
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
(window as any).RTCPeerConnection = function (...args: any[]) {
|
||||||
|
const pc = new OriginalRTCPeerConnection(...args);
|
||||||
|
connections.push(pc);
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
(window as any).__lastRtcState = pc.connectionState;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track remote streams
|
||||||
|
pc.addEventListener('track', (event) => {
|
||||||
|
if (!((window as any).__rtcRemoteTracks)) {
|
||||||
|
(window as any).__rtcRemoteTracks = [];
|
||||||
|
}
|
||||||
|
(window as any).__rtcRemoteTracks.push({
|
||||||
|
kind: event.track.kind,
|
||||||
|
id: event.track.id,
|
||||||
|
readyState: event.track.readyState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return pc;
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Preserve prototype chain
|
||||||
|
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voice Call Test Pattern — Full Example
|
||||||
|
|
||||||
|
This is the canonical pattern for testing that voice actually connects between two clients:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '../fixtures/multi-client';
|
||||||
|
import { installWebRTCTracking, getAudioStats } from '../helpers/webrtc-helpers';
|
||||||
|
|
||||||
|
test.describe('Voice Call', () => {
|
||||||
|
test('two users connect voice and exchange audio', async ({ createClient }) => {
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
// Install WebRTC tracking BEFORE navigation
|
||||||
|
await installWebRTCTracking(alice.page);
|
||||||
|
await installWebRTCTracking(bob.page);
|
||||||
|
|
||||||
|
await test.step('Both users log in', async () => {
|
||||||
|
// Login Alice
|
||||||
|
await alice.page.goto('/login');
|
||||||
|
await alice.page.getByLabel('Email').fill('alice@test.com');
|
||||||
|
await alice.page.getByLabel('Password').fill('password123');
|
||||||
|
await alice.page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/search/);
|
||||||
|
|
||||||
|
// Login Bob
|
||||||
|
await bob.page.goto('/login');
|
||||||
|
await bob.page.getByLabel('Email').fill('bob@test.com');
|
||||||
|
await bob.page.getByLabel('Password').fill('password123');
|
||||||
|
await bob.page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(bob.page).toHaveURL(/\/search/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both users join the same room', async () => {
|
||||||
|
// Navigate to a shared room (adapt URL to actual room ID)
|
||||||
|
const roomUrl = '/room/test-room-id';
|
||||||
|
await alice.page.goto(roomUrl);
|
||||||
|
await bob.page.goto(roomUrl);
|
||||||
|
|
||||||
|
// Verify both are in the room
|
||||||
|
await expect(alice.page.locator('app-chat-room')).toBeVisible();
|
||||||
|
await expect(bob.page.locator('app-chat-room')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice starts voice', async () => {
|
||||||
|
// Click the voice/call join button (adapt selector to actual UI)
|
||||||
|
await alice.page.getByRole('button', { name: /join voice|connect/i }).click();
|
||||||
|
|
||||||
|
// Voice workspace should appear
|
||||||
|
await expect(alice.page.locator('app-voice-workspace')).toBeVisible();
|
||||||
|
|
||||||
|
// Voice controls should be visible
|
||||||
|
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob joins voice', async () => {
|
||||||
|
await bob.page.getByRole('button', { name: /join voice|connect/i }).click();
|
||||||
|
await expect(bob.page.locator('app-voice-workspace')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('WebRTC connection establishes', async () => {
|
||||||
|
// Wait for peer connection to reach 'connected' on both sides
|
||||||
|
await alice.page.waitForFunction(
|
||||||
|
() => (window as any).__rtcConnections?.some(
|
||||||
|
(pc: any) => pc.connectionState === 'connected'
|
||||||
|
),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
await bob.page.waitForFunction(
|
||||||
|
() => (window as any).__rtcConnections?.some(
|
||||||
|
(pc: any) => pc.connectionState === 'connected'
|
||||||
|
),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Audio is flowing in both directions', async () => {
|
||||||
|
// Wait a moment for audio stats to accumulate
|
||||||
|
await alice.page.waitForTimeout(3_000); // Acceptable here: waiting for stats accumulation
|
||||||
|
|
||||||
|
// Check Alice is sending audio
|
||||||
|
const aliceStats = await getAudioStats(alice.page);
|
||||||
|
expect(aliceStats.outbound).not.toBeNull();
|
||||||
|
expect(aliceStats.outbound!.bytesSent).toBeGreaterThan(0);
|
||||||
|
expect(aliceStats.outbound!.packetsSent).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check Bob is sending audio
|
||||||
|
const bobStats = await getAudioStats(bob.page);
|
||||||
|
expect(bobStats.outbound).not.toBeNull();
|
||||||
|
expect(bobStats.outbound!.bytesSent).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check Alice receives Bob's audio
|
||||||
|
expect(aliceStats.inbound).not.toBeNull();
|
||||||
|
expect(aliceStats.inbound!.bytesReceived).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check Bob receives Alice's audio
|
||||||
|
expect(bobStats.inbound).not.toBeNull();
|
||||||
|
expect(bobStats.inbound!.bytesReceived).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Voice UI states are correct', async () => {
|
||||||
|
// Both should see stream tiles for each other
|
||||||
|
await expect(alice.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
|
||||||
|
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Mute button should be visible and in unmuted state
|
||||||
|
// (lucideMic icon visible, lucideMicOff NOT visible)
|
||||||
|
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Mute toggles correctly', async () => {
|
||||||
|
// Alice mutes
|
||||||
|
await alice.page.getByRole('button', { name: /mute/i }).click();
|
||||||
|
|
||||||
|
// Alice's local UI shows muted state
|
||||||
|
// Bob should see Alice as muted (mute indicator on her tile)
|
||||||
|
|
||||||
|
// Verify no audio being sent from Alice after mute
|
||||||
|
const preStats = await getAudioStats(alice.page);
|
||||||
|
await alice.page.waitForTimeout(2_000);
|
||||||
|
const postStats = await getAudioStats(alice.page);
|
||||||
|
|
||||||
|
// Bytes sent should not increase (or increase minimally — comfort noise)
|
||||||
|
// The exact assertion depends on whether mute stops the track or sends silence
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice hangs up', async () => {
|
||||||
|
await alice.page.getByRole('button', { name: /hang up|disconnect|leave/i }).click();
|
||||||
|
|
||||||
|
// Voice workspace should disappear for Alice
|
||||||
|
await expect(alice.page.locator('app-voice-workspace')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Bob should see Alice's tile disappear
|
||||||
|
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying Audio Is Actually Received
|
||||||
|
|
||||||
|
Beyond checking `bytesReceived > 0`, you can verify actual audio energy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Check if audio energy is present on a received stream.
|
||||||
|
* Uses Web Audio AnalyserNode — same approach as MetoYou's VoiceActivityService.
|
||||||
|
*/
|
||||||
|
export async function hasAudioEnergy(page: Page): Promise<boolean> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const connections = (window as any).__rtcConnections as RTCPeerConnection[];
|
||||||
|
if (!connections?.length) return false;
|
||||||
|
|
||||||
|
for (const pc of connections) {
|
||||||
|
const receivers = pc.getReceivers();
|
||||||
|
for (const receiver of receivers) {
|
||||||
|
if (receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue;
|
||||||
|
|
||||||
|
const audioCtx = new AudioContext();
|
||||||
|
const source = audioCtx.createMediaStreamSource(new MediaStream([receiver.track]));
|
||||||
|
const analyser = audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
// Sample over 500ms
|
||||||
|
const dataArray = new Float32Array(analyser.frequencyBinCount);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
analyser.getFloatTimeDomainData(dataArray);
|
||||||
|
|
||||||
|
// Calculate RMS (same as VoiceActivityService)
|
||||||
|
let sum = 0;
|
||||||
|
for (const sample of dataArray) {
|
||||||
|
sum += sample * sample;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / dataArray.length);
|
||||||
|
|
||||||
|
audioCtx.close();
|
||||||
|
|
||||||
|
// MetoYou uses threshold 0.015 — use lower threshold for test
|
||||||
|
if (rms > 0.005) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Speaker/Playback Volume
|
||||||
|
|
||||||
|
MetoYou uses per-peer GainNode chains (0–200%). To verify:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await test.step('Bob adjusts Alice volume to 50%', async () => {
|
||||||
|
// Interact with volume slider in stream tile
|
||||||
|
// (adapt selector to actual volume control UI)
|
||||||
|
const volumeSlider = bob.page.locator('app-voice-workspace-stream-tile')
|
||||||
|
.filter({ hasText: 'Alice' })
|
||||||
|
.getByRole('slider');
|
||||||
|
|
||||||
|
await volumeSlider.fill('50');
|
||||||
|
|
||||||
|
// Verify the gain was set (check via WebRTC or app state)
|
||||||
|
const gain = await bob.page.evaluate(() => {
|
||||||
|
// Access VoicePlaybackService through Angular DI if exposed
|
||||||
|
// Or check audio element volume
|
||||||
|
const audioElements = document.querySelectorAll('audio');
|
||||||
|
return audioElements[0]?.volume;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Screen Share
|
||||||
|
|
||||||
|
Screen share requires `getDisplayMedia()` which cannot be auto-granted. Options:
|
||||||
|
|
||||||
|
1. **Mock at the browser level** — use `page.addInitScript()` to replace `getDisplayMedia` with a fake stream
|
||||||
|
2. **Use Chromium flags** — `--auto-select-desktop-capture-source=Entire screen`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mock getDisplayMedia before navigation
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
navigator.mediaDevices.getDisplayMedia = async () => {
|
||||||
|
// Create a simple canvas stream as fake screen share
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 1280;
|
||||||
|
canvas.height = 720;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = '#4a90d9';
|
||||||
|
ctx.fillRect(0, 0, 1280, 720);
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.font = '48px sans-serif';
|
||||||
|
ctx.fillText('Fake Screen Share', 400, 380);
|
||||||
|
return canvas.captureStream(30);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### Trace Viewer
|
||||||
|
When a WebRTC test fails, the trace captures network requests, console logs, and screenshots:
|
||||||
|
```bash
|
||||||
|
npx playwright show-trace test-results/trace.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console Log Forwarding
|
||||||
|
Forward browser console to Node for real-time debugging:
|
||||||
|
```typescript
|
||||||
|
page.on('console', msg => console.log(`[${name}]`, msg.text()));
|
||||||
|
page.on('pageerror', err => console.error(`[${name}] PAGE ERROR:`, err.message));
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebRTC Internals
|
||||||
|
Chromium exposes WebRTC stats at `chrome://webrtc-internals/`. In Playwright, access the same data via:
|
||||||
|
```typescript
|
||||||
|
const stats = await page.evaluate(async () => {
|
||||||
|
const pcs = (window as any).__rtcConnections;
|
||||||
|
return Promise.all(pcs.map(async (pc: any) => {
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
const result: any[] = [];
|
||||||
|
stats.forEach((report: any) => result.push(report));
|
||||||
|
return result;
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timeout Guidelines
|
||||||
|
|
||||||
|
| Operation | Recommended Timeout |
|
||||||
|
|-----------|-------------------|
|
||||||
|
| Page navigation | 10s (default) |
|
||||||
|
| Login flow | 15s |
|
||||||
|
| WebRTC connection establishment | 30s |
|
||||||
|
| ICE negotiation (TURN fallback) | 45s |
|
||||||
|
| Audio stats accumulation | 3–5s after connection |
|
||||||
|
| Full voice test (end-to-end) | 90s |
|
||||||
|
| Screen share setup | 15s |
|
||||||
|
|
||||||
|
## Parallelism Warning
|
||||||
|
|
||||||
|
WebRTC multi-client tests **must** run with `workers: 1` (sequential) because:
|
||||||
|
- All clients share the same signaling server instance
|
||||||
|
- Server state (rooms, users) is mutable
|
||||||
|
- ICE candidates reference `localhost` — port conflicts possible with parallel launches
|
||||||
|
|
||||||
|
If you need parallelism, use separate server instances with different ports per worker.
|
||||||
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal file
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Project Setup — Playwright E2E for MetoYou
|
||||||
|
|
||||||
|
Before creating any tests or changing them read AGENTS.md for a understanding how the application works.
|
||||||
|
|
||||||
|
## First-Time Scaffold
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @playwright/test
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Only Chromium is needed — WebRTC fake media flags are Chromium-only. Add Firefox/WebKit later if needed for non-WebRTC tests.
|
||||||
|
|
||||||
|
### 2. Create Directory Structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p e2e/{tests/{auth,chat,voice,settings},fixtures,pages,helpers}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Config
|
||||||
|
|
||||||
|
Create `e2e/playwright.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
|
||||||
|
outputDir: '../test-results/artifacts',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4200',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'on-first-retry',
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'cd ../server && npm run dev',
|
||||||
|
port: 3001,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'cd ../toju-app && npx ng serve',
|
||||||
|
port: 4200,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create Base Fixture
|
||||||
|
|
||||||
|
Create `e2e/fixtures/base.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export const test = base.extend({
|
||||||
|
// Add common fixtures here as the test suite grows
|
||||||
|
// Examples: authenticated page, test data seeding, etc.
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Multi-Client Fixture
|
||||||
|
|
||||||
|
Create `e2e/fixtures/multi-client.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for the full fixture code.
|
||||||
|
|
||||||
|
### 6. Create WebRTC Helpers
|
||||||
|
|
||||||
|
Create `e2e/helpers/webrtc-helpers.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for helper functions.
|
||||||
|
|
||||||
|
### 7. Create Isolated Test Server Launcher
|
||||||
|
|
||||||
|
The app requires a signal server. Tests use an isolated instance with its own temporary database so test data never pollutes the dev environment.
|
||||||
|
|
||||||
|
Create `e2e/helpers/start-test-server.js` — a Node.js script that:
|
||||||
|
1. Creates a temp directory under the OS tmpdir
|
||||||
|
2. Writes a `data/variables.json` with `serverPort: 3099`, `serverProtocol: "http"`
|
||||||
|
3. Spawns `ts-node server/src/index.ts` with `cwd` set to the temp dir
|
||||||
|
4. Cleans up the temp dir on exit
|
||||||
|
|
||||||
|
The server's `getRuntimeBaseDir()` returns `process.cwd()`, so setting cwd to the temp dir makes the database go to `<tmpdir>/data/metoyou.sqlite`. Module resolution (`require`/`import`) uses `__dirname`, so server source and `node_modules` resolve correctly from the real `server/` directory.
|
||||||
|
|
||||||
|
Playwright's `webServer` config calls this script and waits for port 3099 to be ready.
|
||||||
|
|
||||||
|
### 8. Create Test Endpoint Seeder
|
||||||
|
|
||||||
|
The Angular app reads signal endpoints from `localStorage['metoyou_server_endpoints']`. By default it falls back to production URLs in `environment.ts`. For tests, seed localStorage with a single endpoint pointing at `http://localhost:3099`.
|
||||||
|
|
||||||
|
Create `e2e/helpers/seed-test-endpoint.ts` — called automatically by the multi-client fixture after creating each browser context. The flow is:
|
||||||
|
1. Navigate to `/` (establishes the origin for localStorage)
|
||||||
|
2. Set `metoyou_server_endpoints` to `[{ id: 'e2e-test-server', url: 'http://localhost:3099', ... }]`
|
||||||
|
3. Set `metoyou_removed_default_server_keys` to suppress production endpoints
|
||||||
|
4. Reload the page so the app picks up the test endpoint
|
||||||
|
|
||||||
|
### 9. Add npm Scripts
|
||||||
|
|
||||||
|
Add to root `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "cd e2e && npx playwright test",
|
||||||
|
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
||||||
|
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
||||||
|
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Update .gitignore
|
||||||
|
|
||||||
|
Add to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Generate Test Audio Fixture (Optional)
|
||||||
|
|
||||||
|
For voice tests with controlled audio input:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires ffmpeg
|
||||||
|
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Dev Stack Integration
|
||||||
|
|
||||||
|
The tests assume the standard MetoYou dev stack:
|
||||||
|
|
||||||
|
- **Signaling server** at `http://localhost:3001` (via `server/npm run dev`)
|
||||||
|
- **Angular dev server** at `http://localhost:4200` (via `toju-app/npx ng serve`)
|
||||||
|
|
||||||
|
The `webServer` config in `playwright.config.ts` starts these automatically if not already running. When running `npm run dev` (full Electron stack) separately, tests will reuse the existing servers.
|
||||||
|
|
||||||
|
## Test Data Requirements
|
||||||
|
|
||||||
|
E2E tests need user accounts to log in with. Options:
|
||||||
|
|
||||||
|
1. **Seed via API** — create users in `test.beforeAll` via the server REST API
|
||||||
|
2. **Pre-seeded database** — maintain a test SQLite database with known accounts
|
||||||
|
3. **Register in test** — use the `/register` flow as a setup step (slower but self-contained)
|
||||||
|
|
||||||
|
Recommended: Option 3 for initial setup, migrate to Option 1 as the test suite grows.
|
||||||
@@ -67,8 +67,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
run: |
|
||||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/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
|
||||||
@@ -120,8 +122,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
run: |
|
||||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/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
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/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
|
||||||
@@ -42,6 +44,10 @@ testem.log
|
|||||||
/typings
|
/typings
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -51,3 +57,6 @@ Thumbs.db
|
|||||||
.certs/
|
.certs/
|
||||||
/server/data/variables.json
|
/server/data/variables.json
|
||||||
dist-server/*
|
dist-server/*
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
doc/**
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,10 +1,14 @@
|
|||||||
|
<img src="./images/icon.png" width="100" height="100">
|
||||||
|
|
||||||
|
|
||||||
# Toju / Zoracord
|
# Toju / Zoracord
|
||||||
|
|
||||||
Desktop chat app with three parts:
|
Desktop chat app with four 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
|
||||||
|
|
||||||
@@ -52,3 +56,64 @@ 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) |
|
||||||
|
|||||||
4
dev.sh
4
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
|||||||
"$DIR/generate-cert.sh"
|
"$DIR/generate-cert.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
|
NG_SERVE="cd toju-app && npx 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="ng serve --host=0.0.0.0"
|
NG_SERVE="cd toju-app && npx 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
|
||||||
|
|||||||
4
e2e/fixtures/base.ts
Normal file
4
e2e/fixtures/base.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export const test = base;
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
57
e2e/fixtures/multi-client.ts
Normal file
57
e2e/fixtures/multi-client.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
test as base,
|
||||||
|
chromium,
|
||||||
|
type Page,
|
||||||
|
type BrowserContext,
|
||||||
|
type Browser
|
||||||
|
} from '@playwright/test';
|
||||||
|
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
||||||
|
|
||||||
|
export type Client = {
|
||||||
|
page: Page;
|
||||||
|
context: BrowserContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiClientFixture = {
|
||||||
|
createClient: () => Promise<Client>;
|
||||||
|
browser: Browser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<MultiClientFixture>({
|
||||||
|
browser: async ({}, use) => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await use(browser);
|
||||||
|
await browser.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
createClient: async ({ browser }, use) => {
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
const factory = async (): Promise<Client> => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
baseURL: 'http://localhost:4200'
|
||||||
|
});
|
||||||
|
|
||||||
|
await installTestServerEndpoint(context);
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
clients.push({ page, context });
|
||||||
|
return { page, context };
|
||||||
|
};
|
||||||
|
|
||||||
|
await use(factory);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await client.context.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
77
e2e/helpers/seed-test-endpoint.ts
Normal file
77
e2e/helpers/seed-test-endpoint.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||||
|
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||||
|
|
||||||
|
type SeededEndpointStorageState = {
|
||||||
|
key: string;
|
||||||
|
removedKey: string;
|
||||||
|
endpoints: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
status: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSeededEndpointStorageState(
|
||||||
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
|
): SeededEndpointStorageState {
|
||||||
|
const endpoint = {
|
||||||
|
id: 'e2e-test-server',
|
||||||
|
name: 'E2E Test Server',
|
||||||
|
url: `http://localhost:${port}`,
|
||||||
|
isActive: true,
|
||||||
|
isDefault: false,
|
||||||
|
status: 'unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
||||||
|
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
||||||
|
endpoints: [endpoint]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
||||||
|
try {
|
||||||
|
const storage = window.localStorage;
|
||||||
|
|
||||||
|
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||||
|
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
|
||||||
|
} catch {
|
||||||
|
// about:blank and some Playwright UI pages deny localStorage access.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTestServerEndpoint(
|
||||||
|
context: BrowserContext,
|
||||||
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
|
): Promise<void> {
|
||||||
|
const storageState = buildSeededEndpointStorageState(port);
|
||||||
|
|
||||||
|
await context.addInitScript(applySeededEndpointStorageState, storageState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed localStorage with a single signal endpoint pointing at the test server.
|
||||||
|
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
|
||||||
|
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
|
||||||
|
* relied upon — calling it in the first goto() landing page is fine since the
|
||||||
|
* page will re-read on next navigation/reload).
|
||||||
|
*
|
||||||
|
* Typical usage:
|
||||||
|
* await page.goto('/');
|
||||||
|
* await seedTestServerEndpoint(page);
|
||||||
|
* await page.reload(); // App now picks up the test endpoint
|
||||||
|
*/
|
||||||
|
export async function seedTestServerEndpoint(
|
||||||
|
page: Page,
|
||||||
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
|
): Promise<void> {
|
||||||
|
const storageState = buildSeededEndpointStorageState(port);
|
||||||
|
|
||||||
|
await page.evaluate(applySeededEndpointStorageState, storageState);
|
||||||
|
}
|
||||||
95
e2e/helpers/start-test-server.js
Normal file
95
e2e/helpers/start-test-server.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Launches an isolated MetoYou signaling server for E2E tests.
|
||||||
|
*
|
||||||
|
* Creates a temporary data directory so the test server gets its own
|
||||||
|
* fresh SQLite database. The server process inherits stdio so Playwright
|
||||||
|
* can watch stdout for readiness and the developer can see logs.
|
||||||
|
*
|
||||||
|
* Cleanup: the temp directory is removed when the process exits.
|
||||||
|
*/
|
||||||
|
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
|
||||||
|
const { join } = require('path');
|
||||||
|
const { tmpdir } = require('os');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
||||||
|
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||||
|
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||||
|
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
||||||
|
|
||||||
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
|
const dataDir = join(tmpDir, 'data');
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(dataDir, 'variables.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
serverPort: parseInt(TEST_PORT, 10),
|
||||||
|
serverProtocol: 'http',
|
||||||
|
serverHost: '',
|
||||||
|
klipyApiKey: '',
|
||||||
|
releaseManifestUrl: '',
|
||||||
|
linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[E2E Server] Temp data dir: ${tmpDir}`);
|
||||||
|
console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
||||||
|
|
||||||
|
// ── Spawn the server with cwd = temp dir ─────────────────────────────
|
||||||
|
// process.cwd() is used by getRuntimeBaseDir() in the server, so data/
|
||||||
|
// (database, variables.json) will resolve to our temp directory.
|
||||||
|
// Module resolution (require/import) uses __dirname, so server source
|
||||||
|
// and node_modules are found from the real server/ directory.
|
||||||
|
const child = spawn(
|
||||||
|
'npx',
|
||||||
|
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||||
|
{
|
||||||
|
cwd: tmpDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: TEST_PORT,
|
||||||
|
SSL: 'false',
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
DB_SYNCHRONIZE: 'true',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
console.error('[E2E Server] Failed to start:', err.message);
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
console.log(`[E2E Server] Exited with code ${code}`);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||||
|
function cleanup() {
|
||||||
|
try {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
// Give child 3s to exit, then force kill
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill('SIGKILL');
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
}, 3_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('exit', cleanup);
|
||||||
134
e2e/helpers/webrtc-helpers.ts
Normal file
134
e2e/helpers/webrtc-helpers.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
|
* Tracks all created peer connections and their remote tracks so tests
|
||||||
|
* can inspect WebRTC state via `page.evaluate()`.
|
||||||
|
*
|
||||||
|
* Call immediately after page creation, before any `goto()`.
|
||||||
|
*/
|
||||||
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
|
||||||
|
(window as any).__rtcConnections = connections;
|
||||||
|
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||||
|
|
||||||
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
|
||||||
|
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
||||||
|
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||||
|
|
||||||
|
connections.push(pc);
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
(window as any).__lastRtcState = pc.connectionState;
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||||
|
(window as any).__rtcRemoteTracks.push({
|
||||||
|
kind: event.track.kind,
|
||||||
|
id: event.track.id,
|
||||||
|
readyState: event.track.readyState
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return pc;
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
|
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
|
||||||
|
*/
|
||||||
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (window as any).__rtcConnections?.some(
|
||||||
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
|
) ?? false,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that a peer connection is still in 'connected' state (not failed/disconnected).
|
||||||
|
*/
|
||||||
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as any).__rtcConnections?.some(
|
||||||
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get outbound and inbound audio RTP stats from the first peer connection.
|
||||||
|
*/
|
||||||
|
export async function getAudioStats(page: Page): Promise<{
|
||||||
|
outbound: { bytesSent: number; packetsSent: number } | null;
|
||||||
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
|
}> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
|
if (!connections?.length)
|
||||||
|
return { outbound: null, inbound: null };
|
||||||
|
|
||||||
|
let outbound: { bytesSent: number; packetsSent: number } | null = null;
|
||||||
|
let inbound: { bytesReceived: number; packetsReceived: number } | null = null;
|
||||||
|
|
||||||
|
for (const pc of connections) {
|
||||||
|
if (pc.connectionState !== 'connected')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
|
stats.forEach((report: any) => {
|
||||||
|
const reportMediaType = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
|
if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) {
|
||||||
|
outbound = {
|
||||||
|
bytesSent: report.bytesSent ?? 0,
|
||||||
|
packetsSent: report.packetsSent ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) {
|
||||||
|
inbound = {
|
||||||
|
bytesReceived: report.bytesReceived ?? 0,
|
||||||
|
packetsReceived: report.packetsReceived ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outbound && inbound)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outbound, inbound };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta.
|
||||||
|
* Useful for verifying audio is actively flowing (bytes increasing).
|
||||||
|
*/
|
||||||
|
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
|
||||||
|
outboundBytesDelta: number;
|
||||||
|
inboundBytesDelta: number;
|
||||||
|
}> {
|
||||||
|
const before = await getAudioStats(page);
|
||||||
|
|
||||||
|
await page.waitForTimeout(durationMs);
|
||||||
|
|
||||||
|
const after = await getAudioStats(page);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
|
||||||
|
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
79
e2e/pages/chat-room.page.ts
Normal file
79
e2e/pages/chat-room.page.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
type Page,
|
||||||
|
type Locator
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
|
export class ChatRoomPage {
|
||||||
|
readonly chatMessages: Locator;
|
||||||
|
readonly voiceWorkspace: Locator;
|
||||||
|
readonly channelsSidePanel: Locator;
|
||||||
|
readonly usersSidePanel: Locator;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.chatMessages = page.locator('app-chat-messages');
|
||||||
|
this.voiceWorkspace = page.locator('app-voice-workspace');
|
||||||
|
this.channelsSidePanel = page.locator('app-rooms-side-panel').first();
|
||||||
|
this.usersSidePanel = page.locator('app-rooms-side-panel').last();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click a voice channel by name in the channels sidebar to join voice. */
|
||||||
|
async joinVoiceChannel(channelName: string) {
|
||||||
|
const channelButton = this.page.locator('app-rooms-side-panel')
|
||||||
|
.getByRole('button', { name: channelName, exact: true });
|
||||||
|
|
||||||
|
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||||
|
await channelButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click "Create Voice Channel" button in the channels sidebar. */
|
||||||
|
async openCreateVoiceChannelDialog() {
|
||||||
|
await this.page.locator('button[title="Create Voice Channel"]').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click "Create Text Channel" button in the channels sidebar. */
|
||||||
|
async openCreateTextChannelDialog() {
|
||||||
|
await this.page.locator('button[title="Create Text Channel"]').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill the channel name in the create channel dialog and confirm. */
|
||||||
|
async createChannel(name: string) {
|
||||||
|
const dialog = this.page.locator('app-confirm-dialog');
|
||||||
|
const channelNameInput = dialog.getByPlaceholder('Channel name');
|
||||||
|
const createButton = dialog.getByRole('button', { name: 'Create', exact: true });
|
||||||
|
|
||||||
|
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await channelNameInput.fill(name);
|
||||||
|
await createButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the voice controls component. */
|
||||||
|
get voiceControls() {
|
||||||
|
return this.page.locator('app-voice-controls');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the mute toggle button inside voice controls. */
|
||||||
|
get muteButton() {
|
||||||
|
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the disconnect/hang-up button (destructive styled). */
|
||||||
|
get disconnectButton() {
|
||||||
|
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all voice stream tiles. */
|
||||||
|
get streamTiles() {
|
||||||
|
return this.page.locator('app-voice-workspace-stream-tile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the count of voice users listed under a voice channel. */
|
||||||
|
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
|
||||||
|
const channelSection = this.page.locator('app-rooms-side-panel')
|
||||||
|
.getByRole('button', { name: channelName })
|
||||||
|
.locator('..');
|
||||||
|
const userAvatars = channelSection.locator('app-user-avatar');
|
||||||
|
|
||||||
|
return userAvatars.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
e2e/pages/login.page.ts
Normal file
29
e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { type Page, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
export class LoginPage {
|
||||||
|
readonly usernameInput: Locator;
|
||||||
|
readonly passwordInput: Locator;
|
||||||
|
readonly serverSelect: Locator;
|
||||||
|
readonly submitButton: Locator;
|
||||||
|
readonly errorText: Locator;
|
||||||
|
readonly registerLink: Locator;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.usernameInput = page.locator('#login-username');
|
||||||
|
this.passwordInput = page.locator('#login-password');
|
||||||
|
this.serverSelect = page.locator('#login-server');
|
||||||
|
this.submitButton = page.getByRole('button', { name: 'Login' });
|
||||||
|
this.errorText = page.locator('.text-destructive');
|
||||||
|
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
await this.usernameInput.fill(username);
|
||||||
|
await this.passwordInput.fill(password);
|
||||||
|
await this.submitButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
e2e/pages/register.page.ts
Normal file
35
e2e/pages/register.page.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { expect, type Page, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
export class RegisterPage {
|
||||||
|
readonly usernameInput: Locator;
|
||||||
|
readonly displayNameInput: Locator;
|
||||||
|
readonly passwordInput: Locator;
|
||||||
|
readonly serverSelect: Locator;
|
||||||
|
readonly submitButton: Locator;
|
||||||
|
readonly errorText: Locator;
|
||||||
|
readonly loginLink: Locator;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.usernameInput = page.locator('#register-username');
|
||||||
|
this.displayNameInput = page.locator('#register-display-name');
|
||||||
|
this.passwordInput = page.locator('#register-password');
|
||||||
|
this.serverSelect = page.locator('#register-server');
|
||||||
|
this.submitButton = page.getByRole('button', { name: 'Create Account' });
|
||||||
|
this.errorText = page.locator('.text-destructive');
|
||||||
|
this.loginLink = page.getByRole('button', { name: 'Login' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(username: string, displayName: string, password: string) {
|
||||||
|
await this.usernameInput.fill(username);
|
||||||
|
await this.displayNameInput.fill(displayName);
|
||||||
|
await this.passwordInput.fill(password);
|
||||||
|
await this.submitButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
e2e/pages/server-search.page.ts
Normal file
65
e2e/pages/server-search.page.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
type Page,
|
||||||
|
type Locator,
|
||||||
|
expect
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
|
export class ServerSearchPage {
|
||||||
|
readonly searchInput: Locator;
|
||||||
|
readonly createServerButton: Locator;
|
||||||
|
readonly settingsButton: Locator;
|
||||||
|
|
||||||
|
// Create server dialog
|
||||||
|
readonly serverNameInput: Locator;
|
||||||
|
readonly serverDescriptionInput: Locator;
|
||||||
|
readonly serverTopicInput: Locator;
|
||||||
|
readonly signalEndpointSelect: Locator;
|
||||||
|
readonly privateCheckbox: Locator;
|
||||||
|
readonly serverPasswordInput: Locator;
|
||||||
|
readonly dialogCreateButton: Locator;
|
||||||
|
readonly dialogCancelButton: Locator;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||||
|
this.settingsButton = page.locator('button[title="Settings"]');
|
||||||
|
|
||||||
|
// Create dialog elements
|
||||||
|
this.serverNameInput = page.locator('#create-server-name');
|
||||||
|
this.serverDescriptionInput = page.locator('#create-server-description');
|
||||||
|
this.serverTopicInput = page.locator('#create-server-topic');
|
||||||
|
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
|
||||||
|
this.privateCheckbox = page.locator('#private');
|
||||||
|
this.serverPasswordInput = page.locator('#create-server-password');
|
||||||
|
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
|
||||||
|
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/search');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createServer(name: string, options?: { description?: string; topic?: string }) {
|
||||||
|
await this.createServerButton.click();
|
||||||
|
await expect(this.serverNameInput).toBeVisible();
|
||||||
|
await this.serverNameInput.fill(name);
|
||||||
|
|
||||||
|
if (options?.description) {
|
||||||
|
await this.serverDescriptionInput.fill(options.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.topic) {
|
||||||
|
await this.serverTopicInput.fill(options.topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dialogCreateButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinSavedRoom(name: string) {
|
||||||
|
await this.page.getByRole('button', { name }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinServerFromSearch(name: string) {
|
||||||
|
await this.page.locator('button', { hasText: name }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
e2e/playwright.config.ts
Normal file
52
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const TEST_SERVER_PORT = Number(process.env.TEST_SERVER_PORT) || 3099;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
|
||||||
|
outputDir: '../test-results/artifacts',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4200',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'on-first-retry',
|
||||||
|
actionTimeout: 15_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
// Isolated test server with its own temporary database.
|
||||||
|
// See e2e/helpers/start-test-server.js for details.
|
||||||
|
command: `node helpers/start-test-server.js`,
|
||||||
|
port: TEST_SERVER_PORT,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
env: { TEST_SERVER_PORT: String(TEST_SERVER_PORT) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'cd ../toju-app && npx ng serve',
|
||||||
|
port: 4200,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForPeerConnected,
|
||||||
|
isPeerStillConnected,
|
||||||
|
getAudioStatsDelta
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full user journey: register → create server → join → voice → verify audio
|
||||||
|
* for 10+ seconds of stable connectivity.
|
||||||
|
*
|
||||||
|
* Uses two independent browser contexts (Alice & Bob) to simulate real
|
||||||
|
* multi-user WebRTC voice chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALICE = { username: `alice_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
|
||||||
|
const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
|
||||||
|
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
test.describe('Full user journey: register → server → voice chat', () => {
|
||||||
|
test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => {
|
||||||
|
test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check
|
||||||
|
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
// Install WebRTC tracking before any navigation
|
||||||
|
await installWebRTCTracking(alice.page);
|
||||||
|
await installWebRTCTracking(bob.page);
|
||||||
|
|
||||||
|
// Forward browser console for debugging
|
||||||
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
|
|
||||||
|
// ── Step 1: Register both users ──────────────────────────────────
|
||||||
|
|
||||||
|
await test.step('Alice registers an account', async () => {
|
||||||
|
const registerPage = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await expect(registerPage.submitButton).toBeVisible();
|
||||||
|
await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password);
|
||||||
|
|
||||||
|
// After registration, app should navigate to /search
|
||||||
|
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob registers an account', async () => {
|
||||||
|
const registerPage = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await expect(registerPage.submitButton).toBeVisible();
|
||||||
|
await registerPage.register(BOB.username, BOB.displayName, BOB.password);
|
||||||
|
|
||||||
|
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 2: Alice creates a server ───────────────────────────────
|
||||||
|
|
||||||
|
await test.step('Alice creates a new server', async () => {
|
||||||
|
const searchPage = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await searchPage.createServer(SERVER_NAME, {
|
||||||
|
description: 'E2E test server for voice testing'
|
||||||
|
});
|
||||||
|
|
||||||
|
// After server creation, app navigates to the room
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 3: Bob joins the server ─────────────────────────────────
|
||||||
|
|
||||||
|
await test.step('Bob finds and joins the server', async () => {
|
||||||
|
const searchPage = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
// Search for the server
|
||||||
|
await searchPage.searchInput.fill(SERVER_NAME);
|
||||||
|
|
||||||
|
// Wait for search results and click the server
|
||||||
|
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await serverCard.click();
|
||||||
|
|
||||||
|
// Bob should be in the room now
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 4: Create a voice channel (if one doesn't exist) ────────
|
||||||
|
|
||||||
|
await test.step('Alice ensures a voice channel is available', async () => {
|
||||||
|
const chatRoom = new ChatRoomPage(alice.page);
|
||||||
|
const existingVoiceChannel = alice.page.locator('app-rooms-side-panel')
|
||||||
|
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
|
||||||
|
const voiceChannelExists = await existingVoiceChannel.count() > 0;
|
||||||
|
|
||||||
|
if (!voiceChannelExists) {
|
||||||
|
// Click "Create Voice Channel" plus button
|
||||||
|
await chatRoom.openCreateVoiceChannelDialog();
|
||||||
|
await chatRoom.createChannel(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
// Wait for the channel to appear
|
||||||
|
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 5: Both users join the voice channel ────────────────────
|
||||||
|
|
||||||
|
await test.step('Alice joins the voice channel', async () => {
|
||||||
|
const chatRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
|
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
// Voice controls should appear (indicates voice is connected)
|
||||||
|
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob joins the voice channel', async () => {
|
||||||
|
const chatRoom = new ChatRoomPage(bob.page);
|
||||||
|
|
||||||
|
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 6: Verify WebRTC connection establishes ─────────────────
|
||||||
|
|
||||||
|
await test.step('WebRTC peer connection reaches "connected" state', async () => {
|
||||||
|
await waitForPeerConnected(alice.page, 30_000);
|
||||||
|
await waitForPeerConnected(bob.page, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 7: Verify audio is flowing in both directions ───────────
|
||||||
|
|
||||||
|
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
||||||
|
// Wait a moment for audio pipeline to stabilize
|
||||||
|
const aliceDelta = await getAudioStatsDelta(alice.page, 3_000);
|
||||||
|
|
||||||
|
expect(aliceDelta.outboundBytesDelta).toBeGreaterThan(0);
|
||||||
|
expect(aliceDelta.inboundBytesDelta).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const bobDelta = await getAudioStatsDelta(bob.page, 3_000);
|
||||||
|
|
||||||
|
expect(bobDelta.outboundBytesDelta).toBeGreaterThan(0);
|
||||||
|
expect(bobDelta.inboundBytesDelta).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 8: Verify UI states are correct ─────────────────────────
|
||||||
|
|
||||||
|
await test.step('Voice UI shows correct state for both users', async () => {
|
||||||
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
const bobRoom = new ChatRoomPage(bob.page);
|
||||||
|
|
||||||
|
// Both should see voice controls with "Connected" status
|
||||||
|
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
|
||||||
|
await expect(bob.page.locator('app-voice-controls')).toBeVisible();
|
||||||
|
|
||||||
|
// Both should see the voice workspace or at least voice users listed
|
||||||
|
// Check that both users appear in the voice channel user list
|
||||||
|
const aliceSeesBob = aliceRoom.channelsSidePanel.getByText(BOB.displayName).first();
|
||||||
|
const bobSeesAlice = bobRoom.channelsSidePanel.getByText(ALICE.displayName).first();
|
||||||
|
|
||||||
|
await expect(aliceSeesBob).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(bobSeesAlice).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 9: Stay connected for 10+ seconds, verify stability ─────
|
||||||
|
|
||||||
|
await test.step('Connection remains stable for 10+ seconds', async () => {
|
||||||
|
// Check connectivity at 0s, 5s, and 10s intervals
|
||||||
|
for (const checkpoint of [
|
||||||
|
0,
|
||||||
|
5_000,
|
||||||
|
5_000
|
||||||
|
]) {
|
||||||
|
if (checkpoint > 0) {
|
||||||
|
await alice.page.waitForTimeout(checkpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliceConnected = await isPeerStillConnected(alice.page);
|
||||||
|
const bobConnected = await isPeerStillConnected(bob.page);
|
||||||
|
|
||||||
|
expect(aliceConnected, 'Alice should still be connected').toBe(true);
|
||||||
|
expect(bobConnected, 'Bob should still be connected').toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 10s total, verify audio is still flowing
|
||||||
|
const aliceDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||||
|
|
||||||
|
expect(aliceDelta.outboundBytesDelta, 'Alice should still be sending audio after 10s').toBeGreaterThan(0);
|
||||||
|
expect(aliceDelta.inboundBytesDelta, 'Alice should still be receiving audio after 10s').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const bobDelta = await getAudioStatsDelta(bob.page, 2_000);
|
||||||
|
|
||||||
|
expect(bobDelta.outboundBytesDelta, 'Bob should still be sending audio after 10s').toBeGreaterThan(0);
|
||||||
|
expect(bobDelta.inboundBytesDelta, 'Bob should still be receiving audio after 10s').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 10: Verify mute/unmute works correctly ──────────────────
|
||||||
|
|
||||||
|
await test.step('Mute toggle works correctly', async () => {
|
||||||
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
|
// Alice mutes - click the first button in voice controls (mute button)
|
||||||
|
await aliceRoom.muteButton.click();
|
||||||
|
|
||||||
|
// After muting, Alice's outbound audio should stop increasing
|
||||||
|
// When muted, bytesSent may still show small comfort noise or zero growth
|
||||||
|
// The key assertion is that Bob's inbound for Alice's stream stops or reduces
|
||||||
|
await getAudioStatsDelta(alice.page, 2_000);
|
||||||
|
|
||||||
|
// Alice unmutes
|
||||||
|
await aliceRoom.muteButton.click();
|
||||||
|
|
||||||
|
// After unmuting, outbound should resume
|
||||||
|
const unmutedDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||||
|
|
||||||
|
expect(unmutedDelta.outboundBytesDelta, 'Audio should flow after unmuting').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 11: Clean disconnect ────────────────────────────────────
|
||||||
|
|
||||||
|
await test.step('Alice disconnects from voice', async () => {
|
||||||
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
|
// Click the disconnect/hang-up button
|
||||||
|
await aliceRoom.disconnectButton.click();
|
||||||
|
|
||||||
|
// Connected controls should collapse for Alice after disconnect
|
||||||
|
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import AutoLaunch from 'auto-launch';
|
import AutoLaunch from 'auto-launch';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
let autoLauncher: AutoLaunch | null = null;
|
let autoLauncher: AutoLaunch | null = null;
|
||||||
|
let autoLaunchPath = '';
|
||||||
|
|
||||||
|
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||||
|
|
||||||
function resolveLaunchPath(): string {
|
function resolveLaunchPath(): string {
|
||||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||||
@@ -13,15 +18,77 @@ function resolveLaunchPath(): string {
|
|||||||
return appImagePath || process.execPath;
|
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 {
|
function getAutoLauncher(): AutoLaunch | null {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autoLauncher) {
|
if (!autoLauncher) {
|
||||||
|
autoLaunchPath = resolveLaunchPath();
|
||||||
autoLauncher = new AutoLaunch({
|
autoLauncher = new AutoLaunch({
|
||||||
name: app.getName(),
|
name: app.getName(),
|
||||||
path: resolveLaunchPath()
|
path: autoLaunchPath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +104,16 @@ async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
|||||||
|
|
||||||
const currentlyEnabled = await launcher.isEnabled();
|
const currentlyEnabled = await launcher.isEnabled();
|
||||||
|
|
||||||
if (currentlyEnabled === enabled) {
|
if (!enabled && currentlyEnabled === enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
if (!currentlyEnabled) {
|
||||||
await launcher.enable();
|
await launcher.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
getDataSource
|
getDataSource
|
||||||
} from '../db/database';
|
} from '../db/database';
|
||||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
import {
|
||||||
|
createWindow,
|
||||||
|
getDockIconPath,
|
||||||
|
getMainWindow,
|
||||||
|
prepareWindowForAppQuit,
|
||||||
|
showMainWindow
|
||||||
|
} from '../window/create-window';
|
||||||
import {
|
import {
|
||||||
setupCqrsHandlers,
|
setupCqrsHandlers,
|
||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
@@ -30,8 +36,13 @@ export function registerAppLifecycle(): void {
|
|||||||
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)
|
||||||
createWindow();
|
void createWindow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +52,8 @@ 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();
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -13,6 +18,11 @@ 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();
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
import {
|
||||||
|
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.getRepository(RoomEntity).delete({ id: roomId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
await manager.getRepository(RoomChannelPermissionEntity).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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(MessageEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
roomId: message.roomId,
|
roomId: message.roomId,
|
||||||
@@ -14,10 +17,12 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
editedAt: message.editedAt ?? null,
|
editedAt: message.editedAt ?? null,
|
||||||
reactions: JSON.stringify(message.reactions ?? []),
|
|
||||||
isDeleted: message.isDeleted ? 1 : 0,
|
isDeleted: message.isDeleted ? 1 : 0,
|
||||||
replyToId: message.replyToId ?? null
|
replyToId: message.replyToId ?? null,
|
||||||
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +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;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: room.id,
|
id: room.id,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
@@ -19,13 +36,20 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
maxUsers: room.maxUsers ?? null,
|
maxUsers: room.maxUsers ?? null,
|
||||||
icon: room.icon ?? null,
|
icon: room.icon ?? null,
|
||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
slowModeInterval: extractSlowModeInterval(room),
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null,
|
|
||||||
sourceId: room.sourceId ?? null,
|
sourceId: room.sourceId ?? null,
|
||||||
sourceName: room.sourceName ?? null,
|
sourceName: room.sourceName ?? null,
|
||||||
sourceUrl: room.sourceUrl ?? null
|
sourceUrl: room.sourceUrl ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(MessageEntity);
|
||||||
const existing = await repo.findOne({ where: { id: messageId } });
|
const existing = await repo.findOne({ where: { id: messageId } });
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (updates.channelId !== undefined)
|
const directFields = [
|
||||||
existing.channelId = updates.channelId ?? null;
|
'senderId',
|
||||||
|
'senderName',
|
||||||
|
'content',
|
||||||
|
'timestamp'
|
||||||
|
] as const;
|
||||||
|
const entity = existing as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
if (updates.senderId !== undefined)
|
for (const field of directFields) {
|
||||||
existing.senderId = updates.senderId;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field];
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.senderName !== undefined)
|
const nullableFields = [
|
||||||
existing.senderName = updates.senderName;
|
'channelId',
|
||||||
|
'editedAt',
|
||||||
|
'replyToId'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (updates.content !== undefined)
|
for (const field of nullableFields) {
|
||||||
existing.content = updates.content;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field] ?? null;
|
||||||
if (updates.timestamp !== undefined)
|
}
|
||||||
existing.timestamp = updates.timestamp;
|
|
||||||
|
|
||||||
if (updates.editedAt !== undefined)
|
|
||||||
existing.editedAt = updates.editedAt ?? null;
|
|
||||||
|
|
||||||
if (updates.reactions !== undefined)
|
|
||||||
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.linkMetadata !== undefined)
|
||||||
existing.replyToId = updates.replyToId ?? null;
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|
||||||
await repo.save(existing);
|
await repo.save(existing);
|
||||||
|
|
||||||
|
if (updates.reactions !== undefined) {
|
||||||
|
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,68 @@
|
|||||||
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,
|
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;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const existing = await repo.findOne({ where: { id: roomId } });
|
const existing = await repo.findOne({ where: { id: roomId } });
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
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 repo.save(existing);
|
||||||
|
await replaceRoomRelations(manager, roomId, {
|
||||||
|
channels,
|
||||||
|
members,
|
||||||
|
roles,
|
||||||
|
roleAssignments,
|
||||||
|
channelPermissions,
|
||||||
|
permissions: rawPermissions
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ 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) {
|
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||||
const isDeleted = !!row.isDeleted;
|
const isDeleted = !!row.isDeleted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -24,9 +33,10 @@ export function rowToMessage(row: MessageEntity) {
|
|||||||
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 ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
replyToId: row.replyToId ?? undefined
|
replyToId: row.replyToId ?? undefined,
|
||||||
|
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +59,30 @@ export function rowToUser(row: UserEntity) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToRoom(row: RoomEntity) {
|
export function rowToRoom(
|
||||||
|
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,
|
||||||
@@ -64,9 +97,13 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
maxUsers: row.maxUsers ?? undefined,
|
maxUsers: row.maxUsers ?? undefined,
|
||||||
icon: row.icon ?? undefined,
|
icon: row.icon ?? undefined,
|
||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
slowModeInterval: row.slowModeInterval,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
permissions: relationPayload.permissions,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined,
|
channels: relationPayload.channels,
|
||||||
|
members: relationPayload.members,
|
||||||
|
roles: relationPayload.roles,
|
||||||
|
roleAssignments: relationPayload.roleAssignments,
|
||||||
|
channelPermissions: relationPayload.channelPermissions,
|
||||||
sourceId: row.sourceId ?? undefined,
|
sourceId: row.sourceId ?? undefined,
|
||||||
sourceName: row.sourceName ?? undefined,
|
sourceName: row.sourceName ?? undefined,
|
||||||
sourceUrl: row.sourceUrl ?? undefined
|
sourceUrl: row.sourceUrl ?? undefined
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
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(rowToRoom);
|
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ 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 } });
|
||||||
|
|
||||||
return row ? rowToMessage(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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);
|
||||||
@@ -12,6 +13,7 @@ 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(rowToMessage);
|
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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) ?? []));
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@ 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 } });
|
||||||
|
|
||||||
return row ? rowToRoom(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
QueryTypeKey,
|
QueryTypeKey,
|
||||||
Query,
|
Query,
|
||||||
GetMessagesQuery,
|
GetMessagesQuery,
|
||||||
|
GetMessagesSinceQuery,
|
||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
@@ -13,6 +14,7 @@ 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';
|
||||||
@@ -27,6 +29,7 @@ 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),
|
||||||
|
|||||||
1002
electron/cqrs/relations.ts
Normal file
1002
electron/cqrs/relations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ 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',
|
||||||
@@ -49,6 +50,7 @@ export interface MessagePayload {
|
|||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactionPayload {
|
export interface ReactionPayload {
|
||||||
@@ -60,6 +62,44 @@ 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;
|
||||||
@@ -91,9 +131,13 @@ export interface RoomPayload {
|
|||||||
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;
|
sourceId?: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
@@ -160,6 +204,7 @@ 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 } }
|
||||||
@@ -174,6 +219,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
|
|||||||
|
|
||||||
export type Query =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
|
| GetMessagesSinceQuery
|
||||||
| GetMessageByIdQuery
|
| GetMessageByIdQuery
|
||||||
| GetReactionsForMessageQuery
|
| GetReactionsForMessageQuery
|
||||||
| GetUserQuery
|
| GetUserQuery
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
autoStart: typeof parsed.autoStart === 'boolean'
|
autoStart: typeof parsed.autoStart === 'boolean'
|
||||||
? parsed.autoStart
|
? parsed.autoStart
|
||||||
: DEFAULT_DESKTOP_SETTINGS.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,
|
||||||
@@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||||
? mergedSettings.autoStart
|
? mergedSettings.autoStart
|
||||||
: DEFAULT_DESKTOP_SETTINGS.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,
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ 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;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
replyToId!: string | null;
|
replyToId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
linkMetadata!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
@@ -45,14 +45,8 @@ export class RoomEntity {
|
|||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
iconUpdatedAt!: number | null;
|
iconUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('integer', { default: 0 })
|
||||||
permissions!: string | null;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
channels!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
members!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
sourceId!: string | null;
|
sourceId!: string | null;
|
||||||
|
|||||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
59
electron/entities/RoomRoleEntity.ts
Normal file
59
electron/entities/RoomRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
23
electron/entities/RoomUserRoleEntity.ts
Normal file
23
electron/entities/RoomUserRoleEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -28,10 +31,23 @@ 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 { consumePendingDeepLink } from '../app/deep-links';
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
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 = [
|
||||||
@@ -85,6 +101,12 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopNotificationPayload {
|
||||||
|
body: string;
|
||||||
|
requestAttention?: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLinuxDisplayServer(): string {
|
function resolveLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -312,11 +334,91 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -331,6 +433,7 @@ export function setupSystemHandlers(): void {
|
|||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
@@ -402,4 +505,56 @@ export function setupSystemHandlers(): void {
|
|||||||
await fsp.mkdir(dirPath, { recursive: true });
|
await fsp.mkdir(dirPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||||
|
if (typeof srcURL !== 'string' || !srcURL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const request = net.request(srcURL);
|
||||||
|
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
|
||||||
|
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', () => resolve(false));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||||
|
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
||||||
|
|
||||||
|
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
const webContents = mainWindow?.webContents;
|
||||||
|
|
||||||
|
if (!webContents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'cut': webContents.cut(); break;
|
||||||
|
case 'copy': webContents.copy(); break;
|
||||||
|
case 'paste': webContents.paste(); break;
|
||||||
|
case 'selectAll': webContents.selectAll(); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddLinkMetadata1000000000005 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// SQLite does not support DROP COLUMN; column is nullable and harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit
|
|||||||
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 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;
|
||||||
@@ -50,6 +51,12 @@ 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[];
|
||||||
@@ -84,6 +91,23 @@ 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 {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -100,6 +124,22 @@ function readLinuxDisplayServer(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuParams {
|
||||||
|
posX: number;
|
||||||
|
posY: number;
|
||||||
|
isEditable: boolean;
|
||||||
|
selectionText: string;
|
||||||
|
linkURL: string;
|
||||||
|
mediaType: string;
|
||||||
|
srcURL: string;
|
||||||
|
editFlags: {
|
||||||
|
canCut: boolean;
|
||||||
|
canCopy: boolean;
|
||||||
|
canPaste: boolean;
|
||||||
|
canSelectAll: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -116,17 +156,28 @@ 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>;
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
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>;
|
||||||
@@ -134,6 +185,7 @@ export interface ElectronAPI {
|
|||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
@@ -141,6 +193,7 @@ export interface ElectronAPI {
|
|||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -157,6 +210,10 @@ export interface ElectronAPI {
|
|||||||
deleteFile: (filePath: string) => Promise<boolean>;
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||||
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -204,9 +261,29 @@ 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'),
|
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'),
|
||||||
@@ -242,6 +319,20 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||||
|
|
||||||
|
onContextMenu: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
|
||||||
|
listener(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on('show-context-menu', wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('show-context-menu', wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||||
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
91
electron/theme-library.ts
Normal file
91
electron/theme-library.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerHealthResponse {
|
||||||
|
releaseManifestUrl?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateVersionInfo {
|
interface UpdateVersionInfo {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,12 @@ 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[];
|
||||||
@@ -78,6 +89,8 @@ 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: [],
|
||||||
@@ -388,6 +401,47 @@ 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.';
|
||||||
@@ -724,6 +778,12 @@ 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;
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ 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
|
||||||
@@ -38,13 +46,124 @@ 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,
|
||||||
@@ -105,10 +224,64 @@ 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.on('context-menu', (_event, params) => {
|
||||||
|
mainWindow?.webContents.send('show-context-menu', {
|
||||||
|
posX: params.x,
|
||||||
|
posY: params.y,
|
||||||
|
isEditable: params.isEditable,
|
||||||
|
selectionText: params.selectionText,
|
||||||
|
linkURL: params.linkURL,
|
||||||
|
mediaType: params.mediaType,
|
||||||
|
srcURL: params.srcURL,
|
||||||
|
editFlags: {
|
||||||
|
canCut: params.editFlags.canCut,
|
||||||
|
canCopy: params.editFlags.canCopy,
|
||||||
|
canPaste: params.editFlags.canPaste,
|
||||||
|
canSelectAll: params.editFlags.canSelectAll
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
@@ -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: ['src/app/**/*.html'],
|
files: ['toju-app/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: {
|
||||||
|
|||||||
248
package-lock.json
generated
248
package-lock.json
generated
@@ -14,6 +14,12 @@
|
|||||||
"@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",
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"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",
|
||||||
@@ -49,6 +56,7 @@
|
|||||||
"@angular/cli": "^21.0.4",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@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/auto-launch": "^5.0.5",
|
||||||
@@ -2697,6 +2705,109 @@
|
|||||||
"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",
|
||||||
@@ -5672,6 +5783,41 @@
|
|||||||
"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",
|
||||||
@@ -5865,6 +6011,12 @@
|
|||||||
"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",
|
||||||
@@ -9186,6 +9338,22 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-beta.47",
|
"version": "1.0.0-beta.47",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
|
||||||
@@ -14138,6 +14306,21 @@
|
|||||||
"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",
|
||||||
@@ -14766,6 +14949,12 @@
|
|||||||
"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",
|
||||||
@@ -24480,6 +24669,53 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
@@ -27782,6 +28018,12 @@
|
|||||||
"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",
|
||||||
@@ -30374,6 +30616,12 @@
|
|||||||
"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",
|
||||||
|
|||||||
36
package.json
36
package.json
@@ -7,22 +7,22 @@
|
|||||||
"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": "ng",
|
"ng": "cd \"toju-app\" && 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=public/rnnoise-worklet.js",
|
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||||
"start": "ng serve",
|
"start": "cd \"toju-app\" && ng serve",
|
||||||
"build": "ng build",
|
"build": "cd \"toju-app\" && 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": "ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "cd \"toju-app\" && 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": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
"electron": "npm run build && npm run build:electron && 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 node tools/launch-electron.js . --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: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 node tools/launch-electron.js . --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",
|
||||||
@@ -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 \"src/app/**/*.html\"",
|
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
"format:check": "prettier --check \"toju-app/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",
|
||||||
@@ -49,7 +49,11 @@
|
|||||||
"release:version": "node tools/resolve-release-version.js",
|
"release:version": "node tools/resolve-release-version.js",
|
||||||
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
|
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
|
||||||
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
||||||
"sort:props": "node tools/sort-template-properties.js"
|
"sort:props": "node tools/sort-template-properties.js",
|
||||||
|
"test:e2e": "cd e2e && npx playwright test",
|
||||||
|
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
||||||
|
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
||||||
|
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@10.9.2",
|
"packageManager": "npm@10.9.2",
|
||||||
@@ -60,6 +64,12 @@
|
|||||||
"@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",
|
||||||
@@ -73,6 +83,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"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",
|
||||||
@@ -95,6 +106,7 @@
|
|||||||
"@angular/cli": "^21.0.4",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@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/auto-launch": "^5.0.5",
|
||||||
|
|||||||
Binary file not shown.
@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
|
|||||||
|
|
||||||
export type ServerHttpProtocol = 'http' | 'https';
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
|
export interface LinkPreviewConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
cacheTtlMinutes: number;
|
||||||
|
maxCacheSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
|
linkPreview: LinkPreviewConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
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_PORT = 3001;
|
||||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
|
||||||
|
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
const HARD_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
|
|||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||||
|
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const enabled = typeof raw.enabled === 'boolean'
|
||||||
|
? raw.enabled
|
||||||
|
: true;
|
||||||
|
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
|
||||||
|
&& Number.isFinite(raw.cacheTtlMinutes)
|
||||||
|
&& raw.cacheTtlMinutes >= 0
|
||||||
|
? raw.cacheTtlMinutes
|
||||||
|
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
|
||||||
|
const maxSize = typeof raw.maxCacheSizeMb === 'number'
|
||||||
|
&& Number.isFinite(raw.maxCacheSizeMb)
|
||||||
|
&& raw.maxCacheSizeMb >= 0
|
||||||
|
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
|
||||||
|
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
|
||||||
|
|
||||||
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
|
}
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost
|
serverHost: normalized.serverHost,
|
||||||
|
linkPreview: normalized.linkPreview
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
|
|||||||
export function isHttpsServerEnabled(): boolean {
|
export function isHttpsServerEnabled(): boolean {
|
||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
|
return getVariablesConfig().linkPreview;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
@@ -11,9 +16,16 @@ 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.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
await manager.getRepository(ServerRoleEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerEntity).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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(ServerEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
@@ -15,10 +18,18 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
|||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
tags: JSON.stringify(server.tags),
|
slowModeInterval: server.slowModeInterval ?? 0,
|
||||||
createdAt: server.createdAt,
|
createdAt: server.createdAt,
|
||||||
lastSeen: server.lastSeen
|
lastSeen: server.lastSeen
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
await replaceServerRelations(manager, server.id, {
|
||||||
|
tags: server.tags,
|
||||||
|
channels: server.channels ?? [],
|
||||||
|
roles: server.roles ?? [],
|
||||||
|
roleAssignments: server.roleAssignments ?? [],
|
||||||
|
channelPermissions: server.channelPermissions ?? []
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 {
|
||||||
@@ -17,7 +18,24 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
export function rowToServer(
|
||||||
|
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,
|
||||||
@@ -29,7 +47,12 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
tags: JSON.parse(row.tags || '[]'),
|
slowModeInterval: relationPayload.slowModeInterval,
|
||||||
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
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(rowToServer);
|
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ 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 } });
|
||||||
|
|
||||||
return row ? rowToServer(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToServer(row, relationsByServerId.get(row.id));
|
||||||
}
|
}
|
||||||
|
|||||||
603
server/src/cqrs/relations.ts
Normal file
603
server/src/cqrs/relations.ts
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,53 @@ 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;
|
||||||
@@ -39,7 +86,12 @@ export interface ServerPayload {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
@@ -54,13 +59,18 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity
|
ServerBanEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: DB_FILE,
|
location: DB_FILE,
|
||||||
@@ -80,8 +90,12 @@ export async function initDatabase(): Promise<void> {
|
|||||||
|
|
||||||
console.log('[DB] Connection initialised at:', DB_FILE);
|
console.log('[DB] Connection initialised at:', DB_FILE);
|
||||||
|
|
||||||
|
if (process.env.DB_SYNCHRONIZE !== 'true') {
|
||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
console.log('[DB] Migrations executed');
|
console.log('[DB] Migrations executed');
|
||||||
|
} else {
|
||||||
|
console.log('[DB] Synchronize mode — migrations skipped');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyDatabase(): Promise<void> {
|
export async function destroyDatabase(): Promise<void> {
|
||||||
|
|||||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
@@ -33,8 +33,8 @@ export class ServerEntity {
|
|||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
currentUsers!: number;
|
currentUsers!: number;
|
||||||
|
|
||||||
@Column('text', { default: '[]' })
|
@Column('integer', { default: 0 })
|
||||||
tags!: string;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('integer')
|
@Column('integer')
|
||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
|
|||||||
59
server/src/entities/ServerRoleEntity.ts
Normal file
59
server/src/entities/ServerRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_tags')
|
||||||
|
export class ServerTagEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('integer')
|
||||||
|
position!: number;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
value!: string;
|
||||||
|
}
|
||||||
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
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 { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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
|
||||||
)
|
)
|
||||||
|
|||||||
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
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
|
ServerAccessControl1000000000001,
|
||||||
|
ServerChannels1000000000002,
|
||||||
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
|
NormalizeServerArrays1000000000004,
|
||||||
|
ServerRoleAccessControl1000000000005
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { getAllPublicServers } from '../cqrs';
|
import { getAllPublicServers } from '../cqrs';
|
||||||
import { getReleaseManifestUrl } from '../config/variables';
|
import { getReleaseManifestUrl } from '../config/variables';
|
||||||
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
||||||
import { connectedUsers } from '../websocket/state';
|
import { connectedUsers } from '../websocket/state';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const SERVER_INSTANCE_ID = typeof process.env.METOYOU_SERVER_INSTANCE_ID === 'string'
|
||||||
|
&& process.env.METOYOU_SERVER_INSTANCE_ID.trim().length > 0
|
||||||
|
? process.env.METOYOU_SERVER_INSTANCE_ID.trim()
|
||||||
|
: randomUUID();
|
||||||
|
|
||||||
function getServerProjectVersion(): string {
|
function getServerProjectVersion(): string {
|
||||||
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
|
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
|
||||||
@@ -20,6 +25,7 @@ router.get('/health', async (_req, res) => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
serverCount: servers.length,
|
serverCount: servers.length,
|
||||||
connectedUsers: connectedUsers.size,
|
connectedUsers: connectedUsers.size,
|
||||||
|
serverInstanceId: SERVER_INSTANCE_ID,
|
||||||
serverVersion: getServerProjectVersion(),
|
serverVersion: getServerProjectVersion(),
|
||||||
releaseManifestUrl: getReleaseManifestUrl()
|
releaseManifestUrl: getReleaseManifestUrl()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Express } from 'express';
|
import { Express } from 'express';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
|
import linkMetadataRouter from './link-metadata';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -10,6 +11,7 @@ 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);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
|
app.use('/api', linkMetadataRouter);
|
||||||
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);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getUserById } from '../cqrs';
|
import { getUserById } from '../cqrs';
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
import { getActiveServerInvite } from '../services/server-access.service';
|
import { getActiveServerInvite } from '../services/server-access.service';
|
||||||
import {
|
import {
|
||||||
@@ -283,7 +282,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
const server = bundle.server;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: bundle.invite.id,
|
id: bundle.invite.id,
|
||||||
@@ -315,7 +314,7 @@ invitePageRouter.get('/:id', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
const server = bundle.server;
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
res.send(renderInvitePage({
|
res.send(renderInvitePage({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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();
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const server = await getServerById(request.serverId);
|
const server = await getServerById(request.serverId);
|
||||||
|
|
||||||
if (!server || server.ownerId !== ownerId)
|
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
||||||
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']);
|
||||||
|
|||||||
292
server/src/routes/link-metadata.ts
Normal file
292
server/src/routes/link-metadata.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getLinkPreviewConfig } from '../config/variables';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_HTML_BYTES = 512 * 1024;
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const MAX_FIELD_LENGTH = 512;
|
||||||
|
|
||||||
|
interface CachedMetadata {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
failed?: boolean;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataCache = new Map<string, CachedMetadata>();
|
||||||
|
|
||||||
|
let cacheByteEstimate = 0;
|
||||||
|
|
||||||
|
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
|
||||||
|
let bytes = key.length * 2;
|
||||||
|
|
||||||
|
if (entry.title)
|
||||||
|
bytes += entry.title.length * 2;
|
||||||
|
|
||||||
|
if (entry.description)
|
||||||
|
bytes += entry.description.length * 2;
|
||||||
|
|
||||||
|
if (entry.imageUrl)
|
||||||
|
bytes += entry.imageUrl.length * 2;
|
||||||
|
|
||||||
|
if (entry.siteName)
|
||||||
|
bytes += entry.siteName.length * 2;
|
||||||
|
|
||||||
|
return bytes + 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, entry: CachedMetadata): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
|
||||||
|
|
||||||
|
if (metadataCache.has(key)) {
|
||||||
|
const existing = metadataCache.get(key) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryBytes = estimateEntryBytes(key, entry);
|
||||||
|
|
||||||
|
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
|
||||||
|
const oldest = metadataCache.keys().next().value as string;
|
||||||
|
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
|
||||||
|
metadataCache.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.set(key, entry);
|
||||||
|
cacheByteEstimate += entryBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateField(value: string | undefined): string | undefined {
|
||||||
|
if (!value)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
if (value.length <= MAX_FIELD_LENGTH)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
return value.slice(0, MAX_FIELD_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
|
||||||
|
if (!rawUrl)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = new URL(rawUrl, baseUrl);
|
||||||
|
|
||||||
|
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
return resolved.href;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
|
||||||
|
if (match?.[1])
|
||||||
|
return decodeHtmlEntities(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(html: string, url: string): CachedMetadata {
|
||||||
|
const title = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
|
||||||
|
/<title[^>]*>([^<]+)<\/title>/i
|
||||||
|
]);
|
||||||
|
const description = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
|
||||||
|
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
|
||||||
|
]);
|
||||||
|
const rawImageUrl = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
|
||||||
|
]);
|
||||||
|
const siteNamePatterns = [
|
||||||
|
// eslint-disable-next-line @stylistic/js/array-element-newline
|
||||||
|
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
|
||||||
|
];
|
||||||
|
const siteName = getMetaContent(html, siteNamePatterns);
|
||||||
|
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: truncateField(title),
|
||||||
|
description: truncateField(description),
|
||||||
|
imageUrl,
|
||||||
|
siteName: truncateField(siteName),
|
||||||
|
cachedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictExpired(): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (config.cacheTtlMinutes === 0) {
|
||||||
|
cacheByteEstimate = 0;
|
||||||
|
metadataCache.clear();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [key, entry] of metadataCache) {
|
||||||
|
if (now - entry.cachedAt > ttlMs) {
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, entry);
|
||||||
|
metadataCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/link-metadata', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return res.status(403).json({ error: 'Link previews are disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
evictExpired();
|
||||||
|
|
||||||
|
const cached = metadataCache.get(url);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const { cachedAt, ...metadata } = cached;
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
|
||||||
|
return res.json(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
const response = await safeFetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html',
|
||||||
|
'User-Agent': 'MetoYou-LinkPreview/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (!contentType.includes('text/html')) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const result = await reader.read();
|
||||||
|
|
||||||
|
done = result.done;
|
||||||
|
|
||||||
|
if (result.value) {
|
||||||
|
chunks.push(result.value);
|
||||||
|
totalBytes += result.value.length;
|
||||||
|
|
||||||
|
if (totalBytes > MAX_HTML_BYTES) {
|
||||||
|
reader.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
const metadata = parseMetadata(html, url);
|
||||||
|
|
||||||
|
cacheSet(url, metadata);
|
||||||
|
|
||||||
|
const { cachedAt, ...result } = metadata;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
cacheSet(url, { failed: true, cachedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as { name?: string })?.name === 'AbortError') {
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Link metadata error:', err);
|
||||||
|
res.json({ failed: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid URL' });
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
const response = await safeFetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return res.status(response.status).end();
|
return res.status(response?.status ?? 502).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Response, Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
@@ -27,15 +27,53 @@ import {
|
|||||||
buildInviteUrl,
|
buildInviteUrl,
|
||||||
getRequestOrigin
|
getRequestOrigin
|
||||||
} from './invite-utils';
|
} from './invite-utils';
|
||||||
|
import {
|
||||||
|
canManageServerUpdate,
|
||||||
|
canModerateServerMember,
|
||||||
|
resolveServerPermission
|
||||||
|
} from '../services/server-permissions.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
function normalizeRole(role: unknown): string | null {
|
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
return `${type}:${name.toLocaleLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||||
return !!role && allowedRoles.includes(role);
|
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) {
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
@@ -124,7 +162,8 @@ router.post('/', async (req, res) => {
|
|||||||
isPrivate,
|
isPrivate,
|
||||||
maxUsers,
|
maxUsers,
|
||||||
password,
|
password,
|
||||||
tags
|
tags,
|
||||||
|
channels
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
@@ -143,6 +182,7 @@ router.post('/', async (req, res) => {
|
|||||||
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()
|
||||||
};
|
};
|
||||||
@@ -161,27 +201,35 @@ router.put('/:id', async (req, res) => {
|
|||||||
password,
|
password,
|
||||||
hasPassword: _ignoredHasPassword,
|
hasPassword: _ignoredHasPassword,
|
||||||
passwordHash: _ignoredPasswordHash,
|
passwordHash: _ignoredPasswordHash,
|
||||||
|
channels,
|
||||||
...updates
|
...updates
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
const normalizedRole = normalizeRole(actingRole);
|
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (
|
if (!authenticatedOwnerId) {
|
||||||
existing.ownerId !== authenticatedOwnerId &&
|
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
}
|
||||||
) {
|
|
||||||
|
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 hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
|
||||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
...existing,
|
...existing,
|
||||||
...updates,
|
...updates,
|
||||||
|
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||||
hasPassword: !!nextPasswordHash,
|
hasPassword: !!nextPasswordHash,
|
||||||
passwordHash: nextPasswordHash,
|
passwordHash: nextPasswordHash,
|
||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
@@ -249,7 +297,7 @@ router.post('/:id/invites', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/kick', async (req, res) => {
|
router.post('/:id/moderation/kick', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
const { actorUserId, targetUserId } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -260,14 +308,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +319,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/ban', async (req, res) => {
|
router.post('/:id/moderation/ban', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -289,14 +330,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,21 +349,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/unban', async (req, res) => {
|
router.post('/:id/moderation/unban', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
const { actorUserId, banId, targetUserId } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { lookup } from 'dns/promises';
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
if (
|
||||||
|
ip === '127.0.0.1' ||
|
||||||
|
ip === '::1' ||
|
||||||
|
ip === '0.0.0.0' ||
|
||||||
|
ip === '::'
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 10.x.x.x
|
||||||
|
if (ip.startsWith('10.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 172.16.0.0 - 172.31.255.255
|
||||||
|
if (ip.startsWith('172.')) {
|
||||||
|
const second = parseInt(ip.split('.')[1], 10);
|
||||||
|
|
||||||
|
if (second >= 16 && second <= 31)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.x.x
|
||||||
|
if (ip.startsWith('192.168.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 169.254.x.x (link-local, AWS metadata)
|
||||||
|
if (ip.startsWith('169.254.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// IPv6 private ranges (fc00::/7, fe80::/10)
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
||||||
|
let hostname: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious private hostnames
|
||||||
|
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If hostname is already an IP literal, check it directly
|
||||||
|
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
||||||
|
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { address } = await lookup(hostname);
|
||||||
|
|
||||||
|
return !isPrivateIp(address);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeFetchOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a URL while following redirects safely, validating each
|
||||||
|
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
||||||
|
*
|
||||||
|
* The caller must validate the initial URL with `resolveAndValidateHost`
|
||||||
|
* before calling this function.
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
||||||
|
let currentUrl = url;
|
||||||
|
let response: Response | undefined;
|
||||||
|
|
||||||
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: options.signal,
|
||||||
|
headers: options.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400 && location) {
|
||||||
|
let nextUrl: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
nextUrl = new URL(location, currentUrl).href;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(nextUrl))
|
||||||
|
break;
|
||||||
|
|
||||||
|
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
||||||
|
|
||||||
|
if (!redirectAllowed)
|
||||||
|
break;
|
||||||
|
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ServerMembershipEntity
|
ServerMembershipEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { loadServerRelationsMap } from '../cqrs/relations';
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
|
||||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
@@ -57,6 +58,12 @@ function getBanRepository() {
|
|||||||
return getDataSource().getRepository(ServerBanEntity);
|
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 {
|
function normalizePassword(password?: string | null): string | null {
|
||||||
const normalized = password?.trim() ?? '';
|
const normalized = password?.trim() ?? '';
|
||||||
|
|
||||||
@@ -194,7 +201,7 @@ export async function createServerInvite(
|
|||||||
|
|
||||||
export async function getActiveServerInvite(
|
export async function getActiveServerInvite(
|
||||||
inviteId: string
|
inviteId: string
|
||||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
|
||||||
await pruneExpiredServerAccessArtifacts();
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||||
@@ -214,7 +221,10 @@ export async function getActiveServerInvite(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { invite, server };
|
return {
|
||||||
|
invite,
|
||||||
|
server: await toServerPayload(server)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinServerWithAccess(options: {
|
export async function joinServerWithAccess(options: {
|
||||||
@@ -242,7 +252,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: !!existingMembership,
|
joinedBefore: !!existingMembership,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'membership'
|
via: 'membership'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -260,7 +270,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: !!existingMembership,
|
joinedBefore: !!existingMembership,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'invite'
|
via: 'invite'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -272,7 +282,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: true,
|
joinedBefore: true,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'membership'
|
via: 'membership'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -288,7 +298,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: false,
|
joinedBefore: false,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'password'
|
via: 'password'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -301,7 +311,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: false,
|
joinedBefore: false,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'public'
|
via: 'public'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
191
server/src/services/server-permissions.service.ts
Normal file
191
server/src/services/server-permissions.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -8,8 +10,14 @@ interface WsMessage {
|
|||||||
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
||||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||||
|
|
||||||
|
// Deduplicate by oderId so users with multiple connections (e.g. from
|
||||||
|
// different signal URLs routing to the same server) receive the
|
||||||
|
// broadcast only once.
|
||||||
|
const sentToOderIds = new Set<string>();
|
||||||
|
|
||||||
connectedUsers.forEach((user) => {
|
connectedUsers.forEach((user) => {
|
||||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) {
|
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
|
||||||
|
sentToOderIds.add(user.oderId);
|
||||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||||
user.ws.send(JSON.stringify(message));
|
user.ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -24,6 +32,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
|
||||||
|
const usersByOderId = new Map<string, ConnectedUser>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersByOderId.set(user.oderId, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(usersByOderId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
|
||||||
|
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
|
||||||
|
connectionId !== excludeConnectionId
|
||||||
|
&& user.oderId === oderId
|
||||||
|
&& user.serverIds.has(serverId)
|
||||||
|
&& user.ws.readyState === WebSocket.OPEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
|
||||||
|
const serverIds = new Set<string>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.serverIds.forEach((serverId) => serverIds.add(serverId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(serverIds);
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyUser(oderId: string, message: WsMessage): void {
|
export function notifyUser(oderId: string, message: WsMessage): void {
|
||||||
const user = findUserByOderId(oderId);
|
const user = findUserByOderId(oderId);
|
||||||
|
|
||||||
@@ -33,5 +78,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findUserByOderId(oderId: string) {
|
export function findUserByOderId(oderId: string) {
|
||||||
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
|
let match: ConnectedUser | undefined;
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
match = user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
findUserByOderId,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
getUniqueUsersInServer,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
@@ -8,24 +14,66 @@ interface WsMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageId(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
if (!normalized || normalized === 'undefined' || normalized === 'null') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
.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);
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
|
|
||||||
|
// Close stale connections from the same identity AND the same connection
|
||||||
|
// scope so offer routing always targets the freshest socket (e.g. after
|
||||||
|
// page refresh). Connections with a *different* scope (= a different
|
||||||
|
// signal URL that happens to route to this server) are left untouched so
|
||||||
|
// multi-signal-URL setups don't trigger an eviction loop.
|
||||||
|
connectedUsers.forEach((existing, existingId) => {
|
||||||
|
if (existingId !== connectionId
|
||||||
|
&& existing.oderId === newOderId
|
||||||
|
&& existing.connectionScope === newScope) {
|
||||||
|
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing.ws.close();
|
||||||
|
} catch { /* already closing */ }
|
||||||
|
|
||||||
|
connectedUsers.delete(existingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user.oderId = newOderId;
|
||||||
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
user.connectionScope = newScope;
|
||||||
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> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!sid)
|
if (!sid)
|
||||||
return;
|
return;
|
||||||
@@ -42,37 +90,44 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNewConnectionMembership = !user.serverIds.has(sid);
|
||||||
|
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
|
||||||
|
|
||||||
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 ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(
|
||||||
|
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||||
|
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||||
|
);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNewIdentityMembership) {
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const viewSid = String(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
|
if (!viewSid)
|
||||||
|
return;
|
||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (!leaveSid)
|
if (!leaveSid)
|
||||||
return;
|
return;
|
||||||
@@ -84,17 +139,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
|
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
|
if (remainingServerIds.includes(leaveSid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid,
|
serverId: leaveSid,
|
||||||
serverIds: Array.from(user.serverIds)
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||||
const targetUserId = String(message['targetUserId'] || '');
|
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||||
|
|
||||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||||
|
|
||||||
@@ -128,11 +189,15 @@ 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);
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { broadcastToServer } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
serverId: sid,
|
serverId: sid,
|
||||||
serverIds: []
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
/**
|
||||||
|
* Opaque scope string sent by the client (typically the signal URL it
|
||||||
|
* connected through). Stale-connection eviction only targets connections
|
||||||
|
* that share the same (oderId, connectionScope) pair, so multiple signal
|
||||||
|
* URLs routing to the same server coexist without an eviction loop.
|
||||||
|
*/
|
||||||
|
connectionScope?: string;
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"caveman": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "4d486dd6f9fbb27ce1c51c972c9a5eb25a53236ae05eabf4d076ac1e293f4b7a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
|
||||||
<app-settings-modal />
|
|
||||||
|
|
||||||
<!-- Shared Screen Share Source Picker -->
|
|
||||||
<app-screen-share-source-picker />
|
|
||||||
|
|
||||||
<!-- Shared Debug Console -->
|
|
||||||
<app-debug-console [showLauncher]="false" />
|
|
||||||
230
src/app/app.ts
230
src/app/app.ts
@@ -1,230 +0,0 @@
|
|||||||
/* eslint-disable @angular-eslint/component-class-suffix */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
OnInit,
|
|
||||||
OnDestroy,
|
|
||||||
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';
|
|
||||||
|
|
||||||
interface DeepLinkElectronApi {
|
|
||||||
consumePendingDeepLink?: () => Promise<string | null>;
|
|
||||||
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeepLinkWindow = Window & {
|
|
||||||
electronAPI?: DeepLinkElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
RouterOutlet,
|
|
||||||
ServersRailComponent,
|
|
||||||
TitleBarComponent,
|
|
||||||
FloatingVoiceControlsComponent,
|
|
||||||
SettingsModalComponent,
|
|
||||||
DebugConsoleComponent,
|
|
||||||
ScreenShareSourcePickerComponent
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
styleUrl: './app.scss'
|
|
||||||
})
|
|
||||||
export class App implements OnInit, OnDestroy {
|
|
||||||
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);
|
|
||||||
private deepLinkCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
@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 {}
|
|
||||||
|
|
||||||
await this.setupDesktopDeepLinks();
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
|
||||||
|
|
||||||
if (!currentUserId) {
|
|
||||||
if (!this.isPublicRoute(this.router.url)) {
|
|
||||||
this.router.navigate(['/login'], {
|
|
||||||
queryParams: {
|
|
||||||
returnUrl: this.router.url
|
|
||||||
}
|
|
||||||
}).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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.deepLinkCleanup?.();
|
|
||||||
this.deepLinkCleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupDesktopDeepLinks(): Promise<void> {
|
|
||||||
const electronApi = this.getDeepLinkElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
|
|
||||||
void this.handleDesktopDeepLink(url);
|
|
||||||
}) || null;
|
|
||||||
|
|
||||||
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
|
|
||||||
|
|
||||||
if (pendingDeepLink) {
|
|
||||||
await this.handleDesktopDeepLink(pendingDeepLink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDesktopDeepLink(url: string): Promise<void> {
|
|
||||||
const invite = this.parseDesktopInviteUrl(url);
|
|
||||||
|
|
||||||
if (!invite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.router.navigate(['/invite', invite.inviteId], {
|
|
||||||
queryParams: {
|
|
||||||
server: invite.sourceUrl
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
? (window as DeepLinkWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPublicRoute(url: string): boolean {
|
|
||||||
return url === '/login' ||
|
|
||||||
url === '/register' ||
|
|
||||||
url.startsWith('/invite/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
if (parsedUrl.protocol !== 'toju:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
|
||||||
.map((segment) => decodeURIComponent(segment));
|
|
||||||
|
|
||||||
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
|
|
||||||
|
|
||||||
if (!sourceUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inviteId: pathSegments[1],
|
|
||||||
sourceUrl
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
|
||||||
|
|
||||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
|
||||||
|
|
||||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
oderId: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
status: UserStatus;
|
|
||||||
role: UserRole;
|
|
||||||
joinedAt: number;
|
|
||||||
peerId?: string;
|
|
||||||
isOnline?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
isRoomOwner?: boolean;
|
|
||||||
voiceState?: VoiceState;
|
|
||||||
screenShareState?: ScreenShareState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomMember {
|
|
||||||
id: string;
|
|
||||||
oderId?: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
role: UserRole;
|
|
||||||
joinedAt: number;
|
|
||||||
lastSeenAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Channel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: ChannelType;
|
|
||||||
position: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
id: string;
|
|
||||||
roomId: string;
|
|
||||||
channelId?: string;
|
|
||||||
senderId: string;
|
|
||||||
senderName: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
editedAt?: number;
|
|
||||||
reactions: Reaction[];
|
|
||||||
isDeleted: boolean;
|
|
||||||
replyToId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: string;
|
|
||||||
messageId: string;
|
|
||||||
oderId: string;
|
|
||||||
userId: string;
|
|
||||||
emoji: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Room {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
hostId: string;
|
|
||||||
password?: string;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
userCount: number;
|
|
||||||
maxUsers?: number;
|
|
||||||
icon?: string;
|
|
||||||
iconUpdatedAt?: number;
|
|
||||||
permissions?: RoomPermissions;
|
|
||||||
channels?: Channel[];
|
|
||||||
members?: RoomMember[];
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomSettings {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
password?: string;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
maxUsers?: number;
|
|
||||||
rules?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomPermissions {
|
|
||||||
adminsManageRooms?: boolean;
|
|
||||||
moderatorsManageRooms?: boolean;
|
|
||||||
adminsManageIcon?: boolean;
|
|
||||||
moderatorsManageIcon?: boolean;
|
|
||||||
allowVoice?: boolean;
|
|
||||||
allowScreenShare?: boolean;
|
|
||||||
allowFileUploads?: boolean;
|
|
||||||
slowModeInterval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BanEntry {
|
|
||||||
oderId: string;
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
bannedBy: string;
|
|
||||||
displayName?: string;
|
|
||||||
reason?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PeerConnection {
|
|
||||||
peerId: string;
|
|
||||||
userId: string;
|
|
||||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
|
||||||
dataChannel?: RTCDataChannel;
|
|
||||||
connection?: RTCPeerConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceState {
|
|
||||||
isConnected: boolean;
|
|
||||||
isMuted: boolean;
|
|
||||||
isDeafened: boolean;
|
|
||||||
isSpeaking: boolean;
|
|
||||||
isMutedByAdmin?: boolean;
|
|
||||||
volume?: number;
|
|
||||||
roomId?: string;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareState {
|
|
||||||
isSharing: boolean;
|
|
||||||
streamId?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SignalingMessageType =
|
|
||||||
| 'offer'
|
|
||||||
| 'answer'
|
|
||||||
| 'ice-candidate'
|
|
||||||
| 'join'
|
|
||||||
| 'leave'
|
|
||||||
| 'chat'
|
|
||||||
| 'state-sync'
|
|
||||||
| 'kick'
|
|
||||||
| 'ban'
|
|
||||||
| 'host-change'
|
|
||||||
| 'room-update';
|
|
||||||
|
|
||||||
export interface SignalingMessage {
|
|
||||||
type: SignalingMessageType;
|
|
||||||
from: string;
|
|
||||||
to?: string;
|
|
||||||
payload: unknown;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChatEventType =
|
|
||||||
| 'message'
|
|
||||||
| 'chat-message'
|
|
||||||
| 'edit'
|
|
||||||
| 'message-edited'
|
|
||||||
| 'delete'
|
|
||||||
| 'message-deleted'
|
|
||||||
| 'reaction'
|
|
||||||
| 'reaction-added'
|
|
||||||
| 'reaction-removed'
|
|
||||||
| 'kick'
|
|
||||||
| 'ban'
|
|
||||||
| 'room-deleted'
|
|
||||||
| 'host-change'
|
|
||||||
| 'room-settings-update'
|
|
||||||
| 'voice-state'
|
|
||||||
| 'chat-inventory-request'
|
|
||||||
| 'chat-inventory'
|
|
||||||
| 'chat-sync-request-ids'
|
|
||||||
| 'chat-sync-batch'
|
|
||||||
| 'chat-sync-summary'
|
|
||||||
| 'chat-sync-request'
|
|
||||||
| 'chat-sync-full'
|
|
||||||
| 'file-announce'
|
|
||||||
| 'file-chunk'
|
|
||||||
| 'file-request'
|
|
||||||
| 'file-cancel'
|
|
||||||
| 'file-not-found'
|
|
||||||
| 'member-roster-request'
|
|
||||||
| 'member-roster'
|
|
||||||
| 'member-leave'
|
|
||||||
| 'voice-state-request'
|
|
||||||
| 'state-request'
|
|
||||||
| 'screen-state'
|
|
||||||
| 'screen-share-request'
|
|
||||||
| 'screen-share-stop'
|
|
||||||
| 'role-change'
|
|
||||||
| 'room-permissions-update'
|
|
||||||
| 'server-icon-summary'
|
|
||||||
| 'server-icon-request'
|
|
||||||
| 'server-icon-full'
|
|
||||||
| 'server-icon-update'
|
|
||||||
| 'server-state-request'
|
|
||||||
| 'server-state-full'
|
|
||||||
| 'unban'
|
|
||||||
| 'channels-update';
|
|
||||||
|
|
||||||
export interface ChatInventoryItem {
|
|
||||||
id: string;
|
|
||||||
ts: number;
|
|
||||||
rc: number;
|
|
||||||
ac?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatAttachmentAnnouncement {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
mime: string;
|
|
||||||
isImage: boolean;
|
|
||||||
uploaderPeerId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
|
|
||||||
messageId: string;
|
|
||||||
filePath?: string;
|
|
||||||
savedPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional fields depend on `type`. */
|
|
||||||
export interface ChatEvent {
|
|
||||||
type: ChatEventType;
|
|
||||||
fromPeerId?: string;
|
|
||||||
messageId?: string;
|
|
||||||
message?: Message;
|
|
||||||
reaction?: Reaction;
|
|
||||||
data?: string | Partial<Message>;
|
|
||||||
timestamp?: number;
|
|
||||||
targetUserId?: string;
|
|
||||||
roomId?: string;
|
|
||||||
items?: ChatInventoryItem[];
|
|
||||||
ids?: string[];
|
|
||||||
messages?: Message[];
|
|
||||||
attachments?: Record<string, ChatAttachmentMeta[]>;
|
|
||||||
total?: number;
|
|
||||||
index?: number;
|
|
||||||
count?: number;
|
|
||||||
lastUpdated?: number;
|
|
||||||
file?: ChatAttachmentAnnouncement;
|
|
||||||
fileId?: string;
|
|
||||||
hostId?: string;
|
|
||||||
hostOderId?: string;
|
|
||||||
previousHostId?: string;
|
|
||||||
previousHostOderId?: string;
|
|
||||||
kickedBy?: string;
|
|
||||||
bannedBy?: string;
|
|
||||||
content?: string;
|
|
||||||
editedAt?: number;
|
|
||||||
deletedAt?: number;
|
|
||||||
deletedBy?: string;
|
|
||||||
oderId?: string;
|
|
||||||
displayName?: string;
|
|
||||||
emoji?: string;
|
|
||||||
reason?: string;
|
|
||||||
settings?: Partial<RoomSettings>;
|
|
||||||
permissions?: Partial<RoomPermissions>;
|
|
||||||
voiceState?: Partial<VoiceState>;
|
|
||||||
isScreenSharing?: boolean;
|
|
||||||
icon?: string;
|
|
||||||
iconUpdatedAt?: number;
|
|
||||||
role?: UserRole;
|
|
||||||
room?: Partial<Room>;
|
|
||||||
channels?: Channel[];
|
|
||||||
members?: RoomMember[];
|
|
||||||
ban?: BanEntry;
|
|
||||||
bans?: BanEntry[];
|
|
||||||
banOderId?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
hostName: string;
|
|
||||||
ownerId?: string;
|
|
||||||
ownerName?: string;
|
|
||||||
ownerPublicKey?: string;
|
|
||||||
userCount: number;
|
|
||||||
maxUsers: number;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
|
||||||
tags?: string[];
|
|
||||||
createdAt: number;
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JoinRequest {
|
|
||||||
roomId: string;
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppState {
|
|
||||||
currentUser: User | null;
|
|
||||||
currentRoom: Room | null;
|
|
||||||
isConnecting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user