feat: Security
This commit is contained in:
@@ -9,7 +9,9 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
|||||||
## Feature list (alphabetical)
|
## Feature list (alphabetical)
|
||||||
|
|
||||||
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
||||||
|
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
||||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||||
|
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
||||||
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
||||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
||||||
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Persisted local user state still requires a session token [authentication] [signaling]
|
||||||
|
|
||||||
|
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
|
||||||
|
- **Rule:** before connecting signaling or loading rooms for a persisted user, require a non-expired token in `metoyou.authTokens`; redirect to `/login` on `SESSION_EXPIRED`, `auth_required`, or `auth_error`.
|
||||||
|
- **Why:** WebSocket `identify` is skipped without a token, so `join_server`, RTC relay, and presence never establish even though the profile exists locally.
|
||||||
|
- **Example:** `hasValidPersistedSession()` in `auth-session.rules.ts` from `loadCurrentUser$`.
|
||||||
|
|
||||||
### Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android]
|
### Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android]
|
||||||
|
|
||||||
- **Trigger:** Android users accept the microphone prompt but voice calls and channels still fail to join.
|
- **Trigger:** Android users accept the microphone prompt but voice calls and channels still fail to join.
|
||||||
|
|||||||
13
agents-docs/adr/0002-session-token-authentication.md
Normal file
13
agents-docs/adr/0002-session-token-authentication.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ADR-0002: Session-Token Authentication on the Signaling Server
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The signaling server trusted client-supplied user IDs on REST mutations and WebSocket `identify`, allowing impersonation for kicks, bans, joins, plugin administration, and push dispatch. The product client already used bearer tokens for the Electron Local API, but the shared signaling server had no equivalent binding between HTTP/WebSocket actions and a logged-in user.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Issue opaque session tokens on login/register, persist them in server SQLite, require `Authorization: Bearer` on all mutating REST routes, and require `identify.token` on WebSocket connections before any other client message is accepted. Actor fields (`currentOwnerId`, `actorUserId`, `requesterUserId`) are derived from the token instead of request bodies.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
This closes identity spoofing without changing the P2P product model: discovery stays public, chat/media still relay over WebSocket, and DM WebRTC signaling remains available across servers. Bcrypt password hashing with transparent SHA-256 upgrade preserves existing accounts. A deprecation window for body-only auth was intentionally omitted so all clients must authenticate in one release, avoiding prolonged dual-trust behavior.
|
||||||
23
agents-docs/adr/0003-signed-message-revisions.md
Normal file
23
agents-docs/adr/0003-signed-message-revisions.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-0003: Signed Message Revision Chains for P2P Chat Integrity
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
P2P chat sync compared timestamps, reaction counts, and attachment counts only. A peer could rewrite history or apply edits out of order with no cryptographic check. The product has no central message store, so integrity must travel with sync traffic and local audit logs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Adopt an append-only **revision chain** per message:
|
||||||
|
|
||||||
|
- Each mutation emits a `MessageRevision` (create, edit, delete, moderation, plugin) with `revision`, `prevRevisionHash`, and `headHash` (SHA-256 over canonical head state).
|
||||||
|
- Inventories advertise `{ revision, headHash }` so peers detect gaps and hash mismatches.
|
||||||
|
- Human-authored revisions are signed with per-user Ed25519 keys; public keys are registered on the signaling server for verification.
|
||||||
|
- Legacy `chat-message` / `message-edited` / `message-deleted` events continue to broadcast alongside `message-revision` for one-release backward compatibility.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Revision chains give deterministic merge (higher valid revision wins) without requiring a trusted relay. Signing binds edits to registered users while keeping chat payloads off the server. Dual emit avoids breaking peers that have not upgraded inventory or revision handlers yet.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- New persistence columns and revision audit stores on browser IDB, Electron SQLite, and Capacitor schemas.
|
||||||
|
- Plugin synthetic users may emit unsigned revisions until a plugin signing model exists.
|
||||||
|
- Attachment byte integrity (SHA-256 on `file-announce`) remains a separate follow-up.
|
||||||
67
agents-docs/features/authentication.md
Normal file
67
agents-docs/features/authentication.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
|
Session-token authentication for the signaling server and product client.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
| Surface | Identity proof | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
||||||
|
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
||||||
|
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type |
|
||||||
|
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
||||||
|
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||||
|
|
||||||
|
## Login / register response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"username": "alice",
|
||||||
|
"displayName": "Alice",
|
||||||
|
"token": "<opaque-hex>",
|
||||||
|
"expiresAt": 1710000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
||||||
|
- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported).
|
||||||
|
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
|
||||||
|
|
||||||
|
## Protected REST routes
|
||||||
|
|
||||||
|
Require `Authorization: Bearer`:
|
||||||
|
|
||||||
|
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
|
||||||
|
- `PUT /api/requests/:id`
|
||||||
|
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
|
||||||
|
- `/api/users/device-tokens/*`
|
||||||
|
- `POST /api/users/logout`
|
||||||
|
|
||||||
|
## WebSocket identify contract
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "identify",
|
||||||
|
"token": "<session-token>",
|
||||||
|
"oderId": "<user-id>",
|
||||||
|
"displayName": "Alice",
|
||||||
|
"connectionScope": "ws://host:3001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `oderId` must match the token's user id when provided.
|
||||||
|
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||||
|
|
||||||
|
## Client storage
|
||||||
|
|
||||||
|
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
||||||
|
|
||||||
|
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home/active signaling server (or any stored token as a fallback). Missing or rejected tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. WebSocket `auth_required` / `auth_error` responses trigger the same path.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- Rate limits: login/register (100 / 15 min), server join (30 / min).
|
||||||
|
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
||||||
|
- Push-token routes require bearer auth and user-id match.
|
||||||
|
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
||||||
60
agents-docs/features/message-integrity.md
Normal file
60
agents-docs/features/message-integrity.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Message Integrity
|
||||||
|
|
||||||
|
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- **Revision chain** — Every create, edit, delete, moderation, or plugin mutation appends a `MessageRevision` with monotonically increasing `revision`, `prevRevisionHash`, and `headHash`.
|
||||||
|
- **Dual emit** — Outgoing mutations broadcast the legacy event (`chat-message`, `message-edited`, `message-deleted`) **and** `message-revision` so older peers keep working while integrity-aware peers prefer revisions.
|
||||||
|
- **Inventory** — Sync inventories include `{ id, ts, rc, ac, revision, headHash }`. Peers re-fetch when remote revision is newer or the same revision has a different hash (tamper detection).
|
||||||
|
- **Signing** — Human authors sign revisions with per-user Ed25519 keys. Public keys are registered on the signaling server; private keys stay in browser `localStorage`.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
| --- | --- |
|
||||||
|
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
||||||
|
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
||||||
|
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) |
|
||||||
|
|
||||||
|
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
||||||
|
|
||||||
|
## Key types
|
||||||
|
|
||||||
|
- `Message.revision`, `Message.headHash` — materialized cache fields on the shared `Message` model.
|
||||||
|
- `MessageRevision` — wire + persistence audit record (`message-revision.models.ts`).
|
||||||
|
- `MessageRevisionType` — `create`, `author-edit`, `author-delete`, `moderate-edit`, `moderate-delete`, `plugin-edit`, `plugin-delete`.
|
||||||
|
- `ChatEvent.type: 'message-revision'` — P2P envelope carrying a full `MessageRevision`.
|
||||||
|
|
||||||
|
## Merge rules
|
||||||
|
|
||||||
|
1. Valid signed revision with higher `revision` wins over legacy timestamp edits.
|
||||||
|
2. Same `revision`, different `headHash` → treat as stale/tampered and re-fetch.
|
||||||
|
3. Unsigned revisions (no `signature`) are accepted for backward compatibility when verification is skipped.
|
||||||
|
4. Legacy peers without `revision`/`headHash` in inventory fall back to `ts` / `rc` / `ac` comparison.
|
||||||
|
|
||||||
|
## Client touchpoints
|
||||||
|
|
||||||
|
- Domain rules: `message-integrity.rules.ts`, `message-revision.builder.rules.ts`, `message-sync.rules.ts`
|
||||||
|
- Services: `MessageRevisionService`, `MessageSigningService`
|
||||||
|
- Store: `messages.effects.ts` (outgoing dual-emit), `messages-incoming.handlers.ts` (`handleMessageRevision`), `messages.helpers.ts` (inventory + merge)
|
||||||
|
- Plugins: `plugin-client-api.service.ts` emits revisions for send/edit/delete
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body / response |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
||||||
|
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
||||||
|
|
||||||
|
Registration runs automatically after login/register via `AuthenticationService`.
|
||||||
|
|
||||||
|
## Degraded-mode behavior
|
||||||
|
|
||||||
|
- Outgoing revision signing is **best-effort**: if `Ed25519` signing fails, the client still broadcasts the legacy `chat-message` envelope (unsigned revision).
|
||||||
|
- Incoming signed revisions are accepted without cryptographic verification when the sender's public key is not yet registered on the server, so chat is not blocked during key-registration races.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `message-integrity.rules.spec.ts`, `message-revision.builder.rules.spec.ts`, `message-revision-signing.rules.spec.ts`, `message-sync.rules.spec.ts`, `messages-incoming.handlers.spec.ts`
|
||||||
|
- Outgoing revision wiring is covered indirectly through existing message effect tests; add focused specs when changing merge or signing behavior.
|
||||||
75
e2e/helpers/auth-api.ts
Normal file
75
e2e/helpers/auth-api.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { type APIRequestContext, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(token: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerTestUser(
|
||||||
|
request: APIRequestContext,
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName?: string
|
||||||
|
): Promise<AuthSession> {
|
||||||
|
const response = await request.post(`${baseUrl}/api/users/register`, {
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
displayName: displayName ?? username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Failed to register test user ${username}: ${response.status()} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginTestUser(
|
||||||
|
request: APIRequestContext,
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthSession> {
|
||||||
|
const response = await request.post(`${baseUrl}/api/users/login`, {
|
||||||
|
data: { username, password }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Failed to login test user ${username}: ${response.status()} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
|
||||||
|
return await page.evaluate(({ storageKey, url }) => {
|
||||||
|
try {
|
||||||
|
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
|
||||||
|
const normalizedUrl = url.trim().replace(/\/+$/, '');
|
||||||
|
const entry = store[normalizedUrl];
|
||||||
|
|
||||||
|
if (!entry || entry.expiresAt <= Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, { storageKey: AUTH_TOKENS_STORAGE_KEY, url: serverUrl });
|
||||||
|
}
|
||||||
@@ -7,16 +7,19 @@
|
|||||||
*
|
*
|
||||||
* Cleanup: the temp directory is removed when the process exits.
|
* Cleanup: the temp directory is removed when the process exits.
|
||||||
*/
|
*/
|
||||||
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
|
const { existsSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
|
||||||
const { join } = require('path');
|
const { join } = require('path');
|
||||||
const { tmpdir } = require('os');
|
const { tmpdir } = require('os');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
||||||
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||||
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
const SERVER_DIST_ENTRY = join(SERVER_DIR, 'dist', 'index.js');
|
||||||
|
const SERVER_SRC_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||||
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
||||||
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
|
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
|
||||||
|
const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_SRC_ENTRY;
|
||||||
|
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
|
||||||
|
|
||||||
// ── Create isolated temp data directory ──────────────────────────────
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
@@ -45,7 +48,7 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
|||||||
// and node_modules are found from the real server/ directory.
|
// and node_modules are found from the real server/ directory.
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
USE_COMPILED_SERVER ? [SERVER_ENTRY] : [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||||
{
|
{
|
||||||
cwd: tmpDir,
|
cwd: tmpDir,
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -317,13 +317,22 @@ export class ChatRoomPage {
|
|||||||
throw new Error('Missing room, user, or endpoint when persisting channels');
|
throw new Error('Missing room, user, or endpoint when persisting channels');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authTokens = JSON.parse(localStorage.getItem('metoyou.authTokens') || '{}') as Record<string, { token: string; expiresAt: number }>;
|
||||||
|
const normalizedApiUrl = apiBaseUrl.trim().replace(/\/+$/, '');
|
||||||
|
const authEntry = authTokens[normalizedApiUrl];
|
||||||
|
const authToken = authEntry && authEntry.expiresAt > Date.now() ? authEntry.token : null;
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
throw new Error('Missing session token for channel persistence');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
|
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
currentOwnerId: currentUser.id,
|
|
||||||
channels: nextChannels
|
channels: nextChannels
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { expect, test } from '../../fixtures/multi-client';
|
import { expect, test } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
authHeaders,
|
||||||
|
registerTestUser,
|
||||||
|
type AuthSession
|
||||||
|
} from '../../helpers/auth-api';
|
||||||
import {
|
import {
|
||||||
getPluginApiTestEvent,
|
getPluginApiTestEvent,
|
||||||
readPluginApiTestManifest,
|
readPluginApiTestManifest,
|
||||||
@@ -9,8 +14,6 @@ import {
|
|||||||
TEST_PLUGIN_RELAY_EVENT
|
TEST_PLUGIN_RELAY_EVENT
|
||||||
} from '../../helpers/plugin-api-test-fixture';
|
} from '../../helpers/plugin-api-test-fixture';
|
||||||
|
|
||||||
const OWNER_USER_ID = 'plugin-api-owner';
|
|
||||||
|
|
||||||
interface CreatedServerResponse {
|
interface CreatedServerResponse {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -54,10 +57,25 @@ interface TestSocket {
|
|||||||
test.describe('Plugin support API', () => {
|
test.describe('Plugin support API', () => {
|
||||||
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
|
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
|
||||||
const manifest = await readPluginApiTestManifest();
|
const manifest = await readPluginApiTestManifest();
|
||||||
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
|
const owner = await registerTestUser(
|
||||||
|
request,
|
||||||
|
testServer.url,
|
||||||
|
`plugin-owner-${Date.now()}`,
|
||||||
|
'TestPass123!',
|
||||||
|
'Plugin Owner'
|
||||||
|
);
|
||||||
|
const peer = await registerTestUser(
|
||||||
|
request,
|
||||||
|
testServer.url,
|
||||||
|
`plugin-peer-${Date.now()}`,
|
||||||
|
'TestPass123!',
|
||||||
|
'Plugin Peer'
|
||||||
|
);
|
||||||
|
const server = await createServer(request, testServer.url, owner, `Plugin API ${Date.now()}`);
|
||||||
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
|
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
|
||||||
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
|
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
|
||||||
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
|
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
|
||||||
|
const ownerHeaders = authHeaders(owner.token);
|
||||||
|
|
||||||
await test.step('Initial snapshot is empty', async () => {
|
await test.step('Initial snapshot is empty', async () => {
|
||||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||||
@@ -71,8 +89,8 @@ test.describe('Plugin support API', () => {
|
|||||||
|
|
||||||
await test.step('Requirement API enforces server management permission', async () => {
|
await test.step('Requirement API enforces server management permission', async () => {
|
||||||
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
|
headers: authHeaders(peer.token),
|
||||||
data: {
|
data: {
|
||||||
actorUserId: 'not-the-owner',
|
|
||||||
status: 'required'
|
status: 'required'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,8 +101,8 @@ test.describe('Plugin support API', () => {
|
|||||||
|
|
||||||
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
|
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
|
||||||
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
|
headers: ownerHeaders,
|
||||||
data: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
reason: manifest.description,
|
reason: manifest.description,
|
||||||
status: 'required',
|
status: 'required',
|
||||||
versionRange: `^${manifest.version}`
|
versionRange: `^${manifest.version}`
|
||||||
@@ -98,8 +116,8 @@ test.describe('Plugin support API', () => {
|
|||||||
versionRange: `^${manifest.version}`
|
versionRange: `^${manifest.version}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
|
const relayDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, relayEvent);
|
||||||
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
|
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, p2pEvent);
|
||||||
|
|
||||||
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
|
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
|
||||||
direction: 'serverRelay',
|
direction: 'serverRelay',
|
||||||
@@ -123,8 +141,8 @@ test.describe('Plugin support API', () => {
|
|||||||
|
|
||||||
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
|
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
|
||||||
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||||
|
headers: ownerHeaders,
|
||||||
data: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
scope: 'server',
|
scope: 'server',
|
||||||
value: {
|
value: {
|
||||||
@@ -140,15 +158,15 @@ test.describe('Plugin support API', () => {
|
|||||||
params: {
|
params: {
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
scope: 'server',
|
scope: 'server',
|
||||||
userId: OWNER_USER_ID
|
userId: owner.id
|
||||||
}
|
}
|
||||||
}), 410);
|
}), 410);
|
||||||
|
|
||||||
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
|
|
||||||
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||||
|
headers: ownerHeaders,
|
||||||
data: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
scope: 'server'
|
scope: 'server'
|
||||||
}
|
}
|
||||||
}), 410);
|
}), 410);
|
||||||
@@ -161,8 +179,8 @@ test.describe('Plugin support API', () => {
|
|||||||
const bob = await openTestSocket(testServer.url);
|
const bob = await openTestSocket(testServer.url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
|
await identifySocket(alice, owner.token, 'Plugin Owner');
|
||||||
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
|
await identifySocket(bob, peer.token, 'Plugin Peer');
|
||||||
alice.send({ type: 'join_server', serverId: server.id });
|
alice.send({ type: 'join_server', serverId: server.id });
|
||||||
bob.send({ type: 'join_server', serverId: server.id });
|
bob.send({ type: 'join_server', serverId: server.id });
|
||||||
|
|
||||||
@@ -193,7 +211,7 @@ test.describe('Plugin support API', () => {
|
|||||||
pluginId: TEST_PLUGIN_ID,
|
pluginId: TEST_PLUGIN_ID,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
sourcePluginUserId: 'fixture-plugin-user',
|
sourcePluginUserId: 'fixture-plugin-user',
|
||||||
sourceUserId: OWNER_USER_ID
|
sourceUserId: owner.id
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
|
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
|
||||||
@@ -237,15 +255,15 @@ test.describe('Plugin support API', () => {
|
|||||||
|
|
||||||
await test.step('Delete APIs remove event definitions and requirements', async () => {
|
await test.step('Delete APIs remove event definitions and requirements', async () => {
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
headers: ownerHeaders
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
headers: ownerHeaders
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
headers: ownerHeaders
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||||
@@ -259,9 +277,11 @@ test.describe('Plugin support API', () => {
|
|||||||
async function createServer(
|
async function createServer(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
owner: AuthSession,
|
||||||
serverName: string
|
serverName: string
|
||||||
): Promise<CreatedServerResponse> {
|
): Promise<CreatedServerResponse> {
|
||||||
const response = await request.post(`${baseUrl}/api/servers`, {
|
const response = await request.post(`${baseUrl}/api/servers`, {
|
||||||
|
headers: authHeaders(owner.token),
|
||||||
data: {
|
data: {
|
||||||
channels: [
|
channels: [
|
||||||
{
|
{
|
||||||
@@ -275,7 +295,7 @@ async function createServer(
|
|||||||
id: `plugin-api-${Date.now()}`,
|
id: `plugin-api-${Date.now()}`,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
name: serverName,
|
name: serverName,
|
||||||
ownerId: OWNER_USER_ID,
|
ownerId: owner.id,
|
||||||
ownerPublicKey: 'plugin-api-owner-public-key',
|
ownerPublicKey: 'plugin-api-owner-public-key',
|
||||||
tags: ['plugins']
|
tags: ['plugins']
|
||||||
}
|
}
|
||||||
@@ -287,13 +307,14 @@ async function createServer(
|
|||||||
async function upsertEventDefinition(
|
async function upsertEventDefinition(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
pluginsApi: string,
|
pluginsApi: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
||||||
): Promise<PluginEventDefinitionResponse> {
|
): Promise<PluginEventDefinitionResponse> {
|
||||||
return await expectJson<PluginEventDefinitionResponse>(await request.put(
|
return await expectJson<PluginEventDefinitionResponse>(await request.put(
|
||||||
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
|
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
|
||||||
{
|
{
|
||||||
|
headers,
|
||||||
data: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
direction: eventDefinition.direction,
|
direction: eventDefinition.direction,
|
||||||
maxPayloadBytes: eventDefinition.maxPayloadBytes,
|
maxPayloadBytes: eventDefinition.maxPayloadBytes,
|
||||||
schemaJson: '{"type":"object"}',
|
schemaJson: '{"type":"object"}',
|
||||||
@@ -309,6 +330,20 @@ async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
|
|||||||
return await response.json() as T;
|
return await response.json() as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function identifySocket(socket: TestSocket, token: string, displayName: string): Promise<void> {
|
||||||
|
socket.send({ type: 'identify', token, displayName });
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
const authError = socket.messages.find((message) => message.type === 'auth_error');
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
throw new Error(`WebSocket identify failed: ${JSON.stringify(authError)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
|
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
|
||||||
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
||||||
const socket = new WebSocket(socketUrl);
|
const socket = new WebSocket(socketUrl);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import {
|
||||||
|
expect,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page
|
||||||
|
} from '@playwright/test';
|
||||||
import { test, type Client } from '../../fixtures/multi-client';
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
|
||||||
import { startTestServer } from '../../helpers/test-server';
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
@@ -11,6 +15,11 @@ import {
|
|||||||
waitForConnectedPeerCount,
|
waitForConnectedPeerCount,
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import {
|
||||||
|
authHeaders,
|
||||||
|
readAuthTokenFromPage,
|
||||||
|
registerTestUser
|
||||||
|
} from '../../helpers/auth-api';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
@@ -104,6 +113,7 @@ function endpointsForGroup(
|
|||||||
test.describe('Mixed signal-config voice', () => {
|
test.describe('Mixed signal-config voice', () => {
|
||||||
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
|
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
|
||||||
createClient,
|
createClient,
|
||||||
|
request,
|
||||||
testServer
|
testServer
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(720_000);
|
test.setTimeout(720_000);
|
||||||
@@ -144,6 +154,26 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||||
// Use a "both" user (client 0) to create both rooms
|
// Use a "both" user (client 0) to create both rooms
|
||||||
const searchPage = new ServerSearchPage(clients[0].page);
|
const searchPage = new ServerSearchPage(clients[0].page);
|
||||||
|
const secondarySession = await registerTestUser(
|
||||||
|
request,
|
||||||
|
secondaryServer.url,
|
||||||
|
clients[0].user.username,
|
||||||
|
clients[0].user.password,
|
||||||
|
clients[0].user.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
await clients[0].page.evaluate(({ serverUrl, token, expiresAt }) => {
|
||||||
|
const storageKey = 'metoyou.authTokens';
|
||||||
|
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
|
||||||
|
const normalizedUrl = serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
store[normalizedUrl] = { token, expiresAt };
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(store));
|
||||||
|
}, {
|
||||||
|
serverUrl: secondaryServer.url,
|
||||||
|
token: secondarySession.token,
|
||||||
|
expiresAt: secondarySession.expiresAt
|
||||||
|
});
|
||||||
|
|
||||||
await searchPage.createServer(VOICE_ROOM_NAME, {
|
await searchPage.createServer(VOICE_ROOM_NAME, {
|
||||||
description: 'Voice room on primary signal',
|
description: 'Voice room on primary signal',
|
||||||
@@ -152,12 +182,14 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
await searchPage.createServer(SECONDARY_ROOM_NAME, {
|
const secondaryRoom = await createServerViaApi(
|
||||||
description: 'Chat room on secondary signal',
|
request,
|
||||||
sourceId: SECONDARY_SIGNAL_ID
|
secondaryServer.url,
|
||||||
});
|
secondarySession,
|
||||||
|
SECONDARY_ROOM_NAME
|
||||||
|
);
|
||||||
|
|
||||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
secondaryRoomId = secondaryRoom.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create invite links ─────────────────────────────────────
|
// ── Create invite links ─────────────────────────────────────
|
||||||
@@ -166,31 +198,39 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// Group D (secondary-only) needs invite to primary room.
|
// Group D (secondary-only) needs invite to primary room.
|
||||||
let primaryRoomInviteUrl: string;
|
let primaryRoomInviteUrl: string;
|
||||||
let secondaryRoomInviteUrl: string;
|
let secondaryRoomInviteUrl: string;
|
||||||
|
let secondaryRoomId = '';
|
||||||
|
|
||||||
await test.step('Create invite links for cross-signal rooms', async () => {
|
await test.step('Create invite links for cross-signal rooms', async () => {
|
||||||
// Navigate to voice room to get its ID
|
// Navigate to voice room to get its ID
|
||||||
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
||||||
const primaryRoomId = await getCurrentRoomId(clients[0].page);
|
const primaryRoomId = await getCurrentRoomId(clients[0].page);
|
||||||
const userId = await getCurrentUserId(clients[0].page);
|
|
||||||
|
|
||||||
// Navigate to secondary room to get its ID
|
|
||||||
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
|
|
||||||
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
|
|
||||||
// Create invite for primary room (voice) via API
|
// Create invite for primary room (voice) via API
|
||||||
|
const primaryToken = await readAuthTokenFromPage(clients[0].page, testServer.url);
|
||||||
|
|
||||||
|
if (!primaryToken) {
|
||||||
|
throw new Error('Missing session token for primary signal invite creation');
|
||||||
|
}
|
||||||
|
|
||||||
const primaryInvite = await createInviteViaApi(
|
const primaryInvite = await createInviteViaApi(
|
||||||
testServer.url,
|
testServer.url,
|
||||||
primaryRoomId,
|
primaryRoomId,
|
||||||
userId,
|
primaryToken,
|
||||||
clients[0].user.displayName
|
clients[0].user.displayName
|
||||||
);
|
);
|
||||||
|
|
||||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||||
|
|
||||||
// Create invite for secondary room (chat) via API
|
// Create invite for secondary room (chat) via API
|
||||||
|
const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);
|
||||||
|
|
||||||
|
if (!secondaryToken) {
|
||||||
|
throw new Error('Missing session token for secondary signal invite creation');
|
||||||
|
}
|
||||||
|
|
||||||
const secondaryInvite = await createInviteViaApi(
|
const secondaryInvite = await createInviteViaApi(
|
||||||
secondaryServer.url,
|
secondaryServer.url,
|
||||||
secondaryRoomId,
|
secondaryRoomId,
|
||||||
userId,
|
secondaryToken,
|
||||||
clients[0].user.displayName
|
clients[0].user.displayName
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -463,17 +503,55 @@ function buildUsers(): TestUser[] {
|
|||||||
|
|
||||||
// ── API helpers ──────────────────────────────────────────────────────
|
// ── API helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createServerViaApi(
|
||||||
|
request: APIRequestContext,
|
||||||
|
serverBaseUrl: string,
|
||||||
|
owner: { id: string; token: string },
|
||||||
|
serverName: string
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const response = await request.post(`${serverBaseUrl}/api/servers`, {
|
||||||
|
headers: authHeaders(owner.token),
|
||||||
|
data: {
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'general-text',
|
||||||
|
name: 'general',
|
||||||
|
position: 0,
|
||||||
|
type: 'text'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
description: `E2E room on ${serverBaseUrl}`,
|
||||||
|
id: `mixed-signal-${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`,
|
||||||
|
isPrivate: false,
|
||||||
|
name: serverName,
|
||||||
|
ownerId: owner.id,
|
||||||
|
ownerPublicKey: 'mixed-signal-owner-public-key',
|
||||||
|
tags: ['e2e']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Failed to create server via API: ${response.status()} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
async function createInviteViaApi(
|
async function createInviteViaApi(
|
||||||
serverBaseUrl: string,
|
serverBaseUrl: string,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string,
|
authToken: string,
|
||||||
displayName: string
|
displayName: string
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
|
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authToken}`
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
requesterUserId: userId,
|
|
||||||
requesterDisplayName: displayName
|
requesterDisplayName: displayName
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -510,34 +588,6 @@ async function getCurrentRoomId(page: Page): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentUserId(page: Page): Promise<string> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserShape {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
throw new Error('Angular debug API unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
|
|
||||||
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error('Current user not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigation helpers ───────────────────────────────────────────────
|
// ── Navigation helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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,
|
||||||
|
revision: message.revision ?? 0,
|
||||||
|
headHash: message.headHash ?? null,
|
||||||
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
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
|||||||
const nullableFields = [
|
const nullableFields = [
|
||||||
'channelId',
|
'channelId',
|
||||||
'editedAt',
|
'editedAt',
|
||||||
'replyToId'
|
'replyToId',
|
||||||
|
'headHash'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const field of nullableFields) {
|
for (const field of nullableFields) {
|
||||||
@@ -44,8 +45,13 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
|||||||
entity[field] = updates[field] ?? null;
|
entity[field] = updates[field] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.isDeleted !== undefined)
|
if (updates.revision !== undefined) {
|
||||||
|
existing.revision = updates.revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.isDeleted !== undefined) {
|
||||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.linkMetadata !== undefined)
|
if (updates.linkMetadata !== undefined)
|
||||||
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
|
|||||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
editedAt: row.editedAt ?? undefined,
|
editedAt: row.editedAt ?? undefined,
|
||||||
|
revision: row.revision ?? 0,
|
||||||
|
headHash: row.headHash ?? undefined,
|
||||||
reactions: isDeleted ? [] : reactions,
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
replyToId: row.replyToId ?? undefined,
|
replyToId: row.replyToId ?? undefined,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface MessagePayload {
|
|||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
|
revision?: number;
|
||||||
|
headHash?: string;
|
||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
|||||||
@@ -41,4 +41,10 @@ export class MessageEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
linkMetadata!: string | null;
|
linkMetadata!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
revision!: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
headHash!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,24 @@ import {
|
|||||||
afterEach
|
afterEach
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
// Mock Electron modules before importing the module under test
|
const {
|
||||||
const mockGetSystemIdleTime = vi.fn(() => 0);
|
mockGetSystemIdleTime,
|
||||||
const mockSend = vi.fn();
|
mockSend,
|
||||||
const mockGetMainWindow = vi.fn(() => ({
|
mockGetMainWindow
|
||||||
isDestroyed: () => false,
|
} = vi.hoisted(() => {
|
||||||
webContents: { send: mockSend }
|
const send = vi.fn();
|
||||||
}));
|
const getSystemIdleTime = vi.fn(() => 0);
|
||||||
|
const getMainWindow = vi.fn(() => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send }
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockGetSystemIdleTime: getSystemIdleTime,
|
||||||
|
mockSend: send,
|
||||||
|
mockGetMainWindow: getMainWindow
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
powerMonitor: {
|
powerMonitor: {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import {
|
|||||||
import { listRunningProcessNames } from '../process-list';
|
import { listRunningProcessNames } from '../process-list';
|
||||||
import { detectActiveGame } from '../game-detection';
|
import { detectActiveGame } from '../game-detection';
|
||||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { clearAllTokens } from '../api/auth-store';
|
||||||
|
import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||||
@@ -72,6 +74,19 @@ const FILE_CLIPBOARD_FORMATS = [
|
|||||||
'public.file-url',
|
'public.file-url',
|
||||||
'FileNameW'
|
'FileNameW'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
async function resolveUserDataFilePath(filePath: string): Promise<string | null> {
|
||||||
|
return await resolveReadablePath(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWritableUserDataFilePath(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await assertPathUnderUserData(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
||||||
'.7z': 'application/x-7z-compressed',
|
'.7z': 'application/x-7z-compressed',
|
||||||
'.aac': 'audio/aac',
|
'.aac': 'audio/aac',
|
||||||
@@ -496,6 +511,10 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'allowedSignalingServers')) {
|
||||||
|
clearAllTokens();
|
||||||
|
}
|
||||||
|
|
||||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
updateCloseToTraySetting(snapshot.closeToTray);
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
@@ -565,6 +584,12 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scopedDestination = await resolveWritableUserDataFilePath(destinationFilePath);
|
||||||
|
|
||||||
|
if (!scopedDestination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fsp.stat(sourceFilePath);
|
const stats = await fsp.stat(sourceFilePath);
|
||||||
|
|
||||||
@@ -572,7 +597,7 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fsp.copyFile(sourceFilePath, destinationFilePath);
|
await fsp.copyFile(sourceFilePath, scopedDestination);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -580,8 +605,14 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
||||||
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.access(filePath, fs.constants.F_OK);
|
await fsp.access(scopedPath, fs.constants.F_OK);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -589,26 +620,40 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
|
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
|
||||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.access(filePath, fs.constants.F_OK);
|
await fsp.access(scopedPath, fs.constants.F_OK);
|
||||||
return pathToFileURL(filePath).toString();
|
return pathToFileURL(scopedPath).toString();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('read-file', async (_event, filePath: string) => {
|
ipcMain.handle('read-file', async (_event, filePath: string) => {
|
||||||
const data = await fsp.readFile(filePath);
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fsp.readFile(scopedPath);
|
||||||
|
|
||||||
return data.toString('base64');
|
return data.toString('base64');
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||||
const fileHandle = await fsp.open(filePath, 'r');
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const safeStart = Math.max(0, Math.trunc(start));
|
const safeStart = Math.max(0, Math.trunc(start));
|
||||||
@@ -623,7 +668,13 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||||
const stats = await fsp.stat(filePath);
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
return stats.size;
|
return stats.size;
|
||||||
});
|
});
|
||||||
@@ -632,23 +683,47 @@ export function setupSystemHandlers(): void {
|
|||||||
return await readClipboardFiles();
|
return await readClipboardFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('grant-plugin-read-root', (_event, rootPath: string) => {
|
||||||
|
grantPluginReadRoot(rootPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
||||||
|
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
await fsp.writeFile(filePath, buffer);
|
await fsp.writeFile(scopedPath, buffer);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
|
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
|
||||||
|
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
await fsp.appendFile(filePath, buffer);
|
await fsp.appendFile(scopedPath, buffer);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||||
|
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.unlink(filePath);
|
await fsp.unlink(scopedPath);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as { code?: string }).code === 'ENOENT') {
|
if ((error as { code?: string }).code === 'ENOENT') {
|
||||||
@@ -683,7 +758,14 @@ export function setupSystemHandlers(): void {
|
|||||||
cancelled: false };
|
cancelled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await fsp.stat(sourceFilePath);
|
const scopedSourcePath = await resolveUserDataFilePath(sourceFilePath);
|
||||||
|
|
||||||
|
if (!scopedSourcePath) {
|
||||||
|
return { saved: false,
|
||||||
|
cancelled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fsp.stat(scopedSourcePath);
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
if (!stats.isFile()) {
|
||||||
return { saved: false,
|
return { saved: false,
|
||||||
@@ -691,7 +773,7 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog({
|
const result = await dialog.showSaveDialog({
|
||||||
defaultPath: defaultFileName || path.basename(sourceFilePath)
|
defaultPath: defaultFileName || path.basename(scopedSourcePath)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
@@ -699,7 +781,7 @@ export function setupSystemHandlers(): void {
|
|||||||
cancelled: true };
|
cancelled: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
await fsp.copyFile(sourceFilePath, result.filePath);
|
await fsp.copyFile(scopedSourcePath, result.filePath);
|
||||||
|
|
||||||
return { saved: true,
|
return { saved: true,
|
||||||
cancelled: false };
|
cancelled: false };
|
||||||
@@ -711,15 +793,22 @@ export function setupSystemHandlers(): void {
|
|||||||
reason: 'missing-path' };
|
reason: 'missing-path' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scopedPath = await resolveUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return { opened: false,
|
||||||
|
reason: 'outside-app-data' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fsp.stat(filePath);
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
if (!stats.isFile()) {
|
||||||
return { opened: false,
|
return { opened: false,
|
||||||
reason: 'not-a-file' };
|
reason: 'not-a-file' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = await shell.openPath(filePath);
|
const error = await shell.openPath(scopedPath);
|
||||||
|
|
||||||
return error
|
return error
|
||||||
? { opened: false,
|
? { opened: false,
|
||||||
@@ -732,7 +821,13 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
|
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
|
||||||
await fsp.mkdir(dirPath, { recursive: true });
|
const scopedPath = await resolveWritableUserDataFilePath(dirPath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(scopedPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class MessageIntegrity1000000000013 implements MigrationInterface {
|
||||||
|
name = 'MessageIntegrity1000000000013';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "revision" integer NOT NULL DEFAULT 0');
|
||||||
|
await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "headHash" text');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "headHash"');
|
||||||
|
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "revision"');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
electron/path-jail.spec.ts
Normal file
70
electron/path-jail.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
afterEach
|
||||||
|
} from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
assertPathUnderRoot,
|
||||||
|
clearGrantedPluginReadRoots,
|
||||||
|
grantPluginReadRoot,
|
||||||
|
resolveReadablePath
|
||||||
|
} from './path-jail';
|
||||||
|
|
||||||
|
describe('path-jail', () => {
|
||||||
|
let tempRoot = '';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-path-jail-'));
|
||||||
|
fs.mkdirSync(path.join(tempRoot, 'server', 'room-1'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tempRoot, 'server', 'room-1', 'file.txt'), 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearGrantedPluginReadRoots();
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts paths inside allowed subdirectories', async () => {
|
||||||
|
const allowedPath = path.join(tempRoot, 'server', 'room-1', 'file.txt');
|
||||||
|
|
||||||
|
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||||
|
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||||
|
fs.mkdirSync(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = path.join(bundleDir, 'main.js');
|
||||||
|
fs.writeFileSync(bundlePath, 'export default {}');
|
||||||
|
|
||||||
|
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside the user data root', async () => {
|
||||||
|
const outsidePath = path.join(os.tmpdir(), 'outside.txt');
|
||||||
|
|
||||||
|
await expect(assertPathUnderRoot(tempRoot, outsidePath, ['server'])).rejects.toThrow('outside allowed app-data paths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside allowed subdirectories', async () => {
|
||||||
|
const pluginsPath = path.join(tempRoot, 'plugins', 'evil.txt');
|
||||||
|
|
||||||
|
await expect(assertPathUnderRoot(tempRoot, pluginsPath, ['server'])).rejects.toThrow('outside allowed app-data paths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows user-granted plugin source roots outside app data', async () => {
|
||||||
|
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
|
||||||
|
const manifestPath = path.join(externalRoot, 'plugin-source.json');
|
||||||
|
fs.writeFileSync(manifestPath, '{}');
|
||||||
|
|
||||||
|
grantPluginReadRoot(externalRoot);
|
||||||
|
|
||||||
|
await expect(resolveReadablePath(manifestPath)).resolves.toBe(manifestPath);
|
||||||
|
|
||||||
|
fs.rmSync(externalRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
108
electron/path-jail.ts
Normal file
108
electron/path-jail.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export const DEFAULT_USER_DATA_SUBDIRS = [
|
||||||
|
'server',
|
||||||
|
'direct-messages',
|
||||||
|
'plugins',
|
||||||
|
'plugin-bundles',
|
||||||
|
'plugin-cache',
|
||||||
|
'themes',
|
||||||
|
'metoyou'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||||
|
const relativePath = path.relative(parentPath, candidatePath);
|
||||||
|
|
||||||
|
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realpathOrSelf(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fsp.realpath(filePath);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowedSubdirs(allowedSubdirs: readonly string[]): string[] {
|
||||||
|
return allowedSubdirs.map((entry) => entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPathUnderRoot(
|
||||||
|
rootPath: string,
|
||||||
|
candidatePath: string,
|
||||||
|
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||||
|
): Promise<string> {
|
||||||
|
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||||
|
throw new Error('Invalid file path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [realRoot, realCandidate] = await Promise.all([realpathOrSelf(rootPath), realpathOrSelf(candidatePath)]);
|
||||||
|
|
||||||
|
if (!isPathInside(realRoot, realCandidate)) {
|
||||||
|
throw new Error('Path is outside allowed app-data paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = path.relative(realRoot, realCandidate).replace(/\\/g, '/');
|
||||||
|
const [topLevelSegment] = relativePath.split('/');
|
||||||
|
|
||||||
|
if (!topLevelSegment || !normalizeAllowedSubdirs(allowedSubdirs).includes(topLevelSegment)) {
|
||||||
|
throw new Error('Path is outside allowed app-data paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
return realCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPathUnderUserData(
|
||||||
|
candidatePath: string,
|
||||||
|
allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS
|
||||||
|
): Promise<string> {
|
||||||
|
return assertPathUnderRoot(app.getPath('userData'), candidatePath, allowedSubdirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantedPluginReadRoots = new Set<string>();
|
||||||
|
|
||||||
|
export function grantPluginReadRoot(rootPath: string): void {
|
||||||
|
if (typeof rootPath !== 'string' || !rootPath.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grantedPluginReadRoots.add(path.resolve(rootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGrantedPluginReadRoots(): void {
|
||||||
|
grantedPluginReadRoots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPathUnderGrantedPluginRoot(candidatePath: string): Promise<string> {
|
||||||
|
const realCandidate = await realpathOrSelf(candidatePath);
|
||||||
|
|
||||||
|
for (const rootPath of grantedPluginReadRoots) {
|
||||||
|
const realRoot = await realpathOrSelf(rootPath);
|
||||||
|
|
||||||
|
if (isPathInside(realRoot, realCandidate)) {
|
||||||
|
return realCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Path is outside allowed app-data paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves readable paths under app data or user-granted plugin source roots. */
|
||||||
|
export async function resolveReadablePath(candidatePath: string): Promise<string | null> {
|
||||||
|
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await assertPathUnderUserData(candidatePath);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return await assertPathUnderGrantedPluginRoot(candidatePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,9 +314,10 @@ export interface ElectronAPI {
|
|||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string | null>;
|
||||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||||
getFileSize: (filePath: string) => Promise<number>;
|
getFileSize: (filePath: string) => Promise<number | null>;
|
||||||
|
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
@@ -451,6 +452,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
|
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
|
||||||
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
|
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
|
||||||
|
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
|
|||||||
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
||||||
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
||||||
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
||||||
|
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
|
|||||||
46
server/package-lock.json
generated
46
server/package-lock.json
generated
@@ -8,9 +8,11 @@
|
|||||||
"name": "metoyou-server",
|
"name": "metoyou-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"sql.js": "^1.9.0",
|
"sql.js": "^1.9.0",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"metoyou-server": "dist/index.js"
|
"metoyou-server": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.14",
|
"@types/cors": "^2.8.14",
|
||||||
"@types/express": "^4.17.18",
|
"@types/express": "^4.17.18",
|
||||||
"@types/node": "^20.8.0",
|
"@types/node": "^20.8.0",
|
||||||
@@ -212,6 +215,13 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -538,6 +548,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -1058,6 +1077,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -1383,6 +1420,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
"dev": "ts-node-dev --respawn src/index.ts"
|
"dev": "ts-node-dev --respawn src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.14",
|
"@types/cors": "^2.8.14",
|
||||||
"@types/express": "^4.17.18",
|
"@types/express": "^4.17.18",
|
||||||
"@types/node": "^20.8.0",
|
"@types/node": "^20.8.0",
|
||||||
|
|||||||
@@ -1,13 +1,53 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import { registerRoutes } from './routes';
|
import { registerRoutes } from './routes';
|
||||||
|
import { getCorsAllowlist } from './config/variables';
|
||||||
|
|
||||||
|
const authRateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many authentication attempts', errorCode: 'RATE_LIMITED' }
|
||||||
|
});
|
||||||
|
const joinRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many join attempts', errorCode: 'RATE_LIMITED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildCorsOptions() {
|
||||||
|
const allowlist = getCorsAllowlist();
|
||||||
|
|
||||||
|
if (allowlist.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
origin(origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) {
|
||||||
|
if (!origin || allowlist.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(new Error('CORS origin not allowed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set('trust proxy', true);
|
// Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY.
|
||||||
app.use(cors());
|
app.set('trust proxy', 'loopback');
|
||||||
|
app.use(cors(buildCorsOptions()));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use('/api/users/login', authRateLimiter);
|
||||||
|
app.use('/api/users/register', authRateLimiter);
|
||||||
|
app.use('/api/servers/:id/join', joinRateLimiter);
|
||||||
|
|
||||||
registerRoutes(app);
|
registerRoutes(app);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface ServerVariablesConfig {
|
|||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
serverTag: string;
|
serverTag: string;
|
||||||
|
corsAllowlist: string[];
|
||||||
linkPreview: LinkPreviewConfig;
|
linkPreview: LinkPreviewConfig;
|
||||||
openApiDocs: OpenApiDocsConfig;
|
openApiDocs: OpenApiDocsConfig;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,17 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
|||||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCorsAllowlist(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
||||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
@@ -169,6 +181,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
serverTag: normalizeServerTag(remainingParsed.serverTag),
|
serverTag: normalizeServerTag(remainingParsed.serverTag),
|
||||||
|
corsAllowlist: normalizeCorsAllowlist(remainingParsed.corsAllowlist),
|
||||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||||
};
|
};
|
||||||
@@ -186,11 +199,23 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost,
|
serverHost: normalized.serverHost,
|
||||||
serverTag: normalized.serverTag,
|
serverTag: normalized.serverTag,
|
||||||
|
corsAllowlist: normalized.corsAllowlist,
|
||||||
linkPreview: normalized.linkPreview,
|
linkPreview: normalized.linkPreview,
|
||||||
openApiDocs: normalized.openApiDocs
|
openApiDocs: normalized.openApiDocs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCorsAllowlist(): string[] {
|
||||||
|
if (hasEnvironmentOverride(process.env.CORS_ALLOWLIST)) {
|
||||||
|
return (process.env.CORS_ALLOWLIST ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().corsAllowlist;
|
||||||
|
}
|
||||||
|
|
||||||
export function getVariablesConfig(): ServerVariablesConfig {
|
export function getVariablesConfig(): ServerVariablesConfig {
|
||||||
return ensureVariablesConfig();
|
return ensureVariablesConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuthUserEntity } from '../../../entities';
|
||||||
|
|
||||||
|
export async function handleUpdateUserPasswordHash(
|
||||||
|
dataSource: DataSource,
|
||||||
|
userId: string,
|
||||||
|
passwordHash: string
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = dataSource.getRepository(AuthUserEntity);
|
||||||
|
|
||||||
|
await repo.update({ id: userId }, { passwordHash });
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuthUserEntity } from '../../../entities';
|
||||||
|
|
||||||
|
export async function handleUpdateUserSigningPublicKey(
|
||||||
|
dataSource: DataSource,
|
||||||
|
userId: string,
|
||||||
|
signingPublicKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = dataSource.getRepository(AuthUserEntity);
|
||||||
|
|
||||||
|
await repo.update({ id: userId }, { signingPublicKey });
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import { handleGetTrendingServers } from './queries/handlers/getTrendingServers'
|
|||||||
import { handleGetServerById } from './queries/handlers/getServerById';
|
import { handleGetServerById } from './queries/handlers/getServerById';
|
||||||
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
||||||
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
||||||
|
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
|
||||||
|
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
|
||||||
|
|
||||||
export const registerUser = (user: AuthUserPayload) =>
|
export const registerUser = (user: AuthUserPayload) =>
|
||||||
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
||||||
@@ -62,3 +64,9 @@ export const getJoinRequestById = (requestId: string) =>
|
|||||||
|
|
||||||
export const getPendingRequestsForServer = (serverId: string) =>
|
export const getPendingRequestsForServer = (serverId: string) =>
|
||||||
handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource());
|
handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource());
|
||||||
|
|
||||||
|
export const updateUserPasswordHash = (userId: string, passwordHash: string) =>
|
||||||
|
handleUpdateUserPasswordHash(getDataSource(), userId, passwordHash);
|
||||||
|
|
||||||
|
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
|
||||||
|
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
passwordHash: row.passwordHash,
|
passwordHash: row.passwordHash,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
createdAt: row.createdAt
|
createdAt: row.createdAt,
|
||||||
|
signingPublicKey: row.signingPublicKey ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AuthUserPayload {
|
|||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
signingPublicKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerChannelType = 'text' | 'voice';
|
export type ServerChannelType = 'text' | 'voice';
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
PluginDataEntity,
|
PluginDataEntity,
|
||||||
ServerPluginSettingsEntity,
|
ServerPluginSettingsEntity,
|
||||||
PluginUserMetadataEntity,
|
PluginUserMetadataEntity,
|
||||||
DeviceTokenEntity
|
DeviceTokenEntity,
|
||||||
|
SessionTokenEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import {
|
import {
|
||||||
@@ -272,7 +273,8 @@ export async function initDatabase(): Promise<void> {
|
|||||||
PluginDataEntity,
|
PluginDataEntity,
|
||||||
ServerPluginSettingsEntity,
|
ServerPluginSettingsEntity,
|
||||||
PluginUserMetadataEntity,
|
PluginUserMetadataEntity,
|
||||||
DeviceTokenEntity
|
DeviceTokenEntity,
|
||||||
|
SessionTokenEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ export class AuthUserEntity {
|
|||||||
|
|
||||||
@Column('integer')
|
@Column('integer')
|
||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
signingPublicKey!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
22
server/src/entities/SessionTokenEntity.ts
Normal file
22
server/src/entities/SessionTokenEntity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('session_tokens')
|
||||||
|
export class SessionTokenEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
token!: string;
|
||||||
|
|
||||||
|
@Index('idx_session_tokens_user_id')
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
issuedAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
expiresAt!: number;
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export { PluginDataEntity } from './PluginDataEntity';
|
|||||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||||
export { DeviceTokenEntity } from './DeviceTokenEntity';
|
export { DeviceTokenEntity } from './DeviceTokenEntity';
|
||||||
|
export { SessionTokenEntity } from './SessionTokenEntity';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ServerHttpProtocol
|
ServerHttpProtocol
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
import { pruneExpiredSessionTokens } from './services/session-auth.service';
|
||||||
|
|
||||||
function formatHostForUrl(host: string): string {
|
function formatHostForUrl(host: string): string {
|
||||||
if (host.startsWith('[') || !host.includes(':')) {
|
if (host.startsWith('[') || !host.includes(':')) {
|
||||||
@@ -61,6 +62,7 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
|||||||
|
|
||||||
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||||
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let sessionTokenPruneInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const variablesConfig = ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
@@ -99,6 +101,11 @@ async function bootstrap(): Promise<void> {
|
|||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
sessionTokenPruneInterval = setInterval(() => {
|
||||||
|
pruneExpiredSessionTokens()
|
||||||
|
.catch(err => console.error('Failed to prune expired session tokens:', err));
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||||
@@ -137,6 +144,11 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|||||||
staleJoinRequestInterval = null;
|
staleJoinRequestInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionTokenPruneInterval) {
|
||||||
|
clearInterval(sessionTokenPruneInterval);
|
||||||
|
sessionTokenPruneInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
||||||
|
|
||||||
if (listeningServer?.listening) {
|
if (listeningServer?.listening) {
|
||||||
|
|||||||
72
server/src/middleware/require-auth.ts
Normal file
72
server/src/middleware/require-auth.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import '../types/express-augmentation';
|
||||||
|
import {
|
||||||
|
NextFunction,
|
||||||
|
Request,
|
||||||
|
Response
|
||||||
|
} from 'express';
|
||||||
|
import { consumeSessionToken } from '../services/session-auth.service';
|
||||||
|
|
||||||
|
function readBearerToken(req: Request): string | null {
|
||||||
|
const header = req.header('authorization');
|
||||||
|
|
||||||
|
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = header.slice(7).trim();
|
||||||
|
|
||||||
|
return token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
const token = readBearerToken(req);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await consumeSessionToken(token);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.authToken = session.token;
|
||||||
|
req.authUserId = session.user.id;
|
||||||
|
req.authUser = session.user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthenticatedUserId(req: Request): string {
|
||||||
|
const userId = req.authUserId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Authenticated user id missing after requireAuth');
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectSpoofedUserId(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
bodyUserId: unknown,
|
||||||
|
fieldName: string
|
||||||
|
): bodyUserId is string {
|
||||||
|
if (typeof bodyUserId !== 'string' || !bodyUserId.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyUserId !== req.authUserId) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `${fieldName} must match the authenticated user`,
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class SessionTokens1000000000011 implements MigrationInterface {
|
||||||
|
name = 'SessionTokens1000000000011';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "session_tokens" (
|
||||||
|
"token" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"issuedAt" INTEGER NOT NULL,
|
||||||
|
"expiresAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_session_tokens_user_id" ON "session_tokens" ("userId")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "session_tokens"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class SigningPublicKey1000000000012 implements MigrationInterface {
|
||||||
|
name = 'SigningPublicKey1000000000012';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "signingPublicKey" text');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "signingPublicKey"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
|||||||
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||||
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
||||||
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
||||||
|
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
|
||||||
|
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -21,5 +23,7 @@ export const serverMigrations = [
|
|||||||
PluginSupport1000000000007,
|
PluginSupport1000000000007,
|
||||||
ServerPluginInstallMetadata1000000000008,
|
ServerPluginInstallMetadata1000000000008,
|
||||||
ServerIcons1000000000009,
|
ServerIcons1000000000009,
|
||||||
DeviceTokens1000000000010
|
DeviceTokens1000000000010,
|
||||||
|
SessionTokens1000000000011,
|
||||||
|
SigningPublicKey1000000000012
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
listDeviceTokensForUser,
|
listDeviceTokensForUser,
|
||||||
upsertDeviceToken
|
upsertDeviceToken
|
||||||
} from '../services/push-dispatch.service';
|
} from '../services/push-dispatch.service';
|
||||||
|
import { requireAuth } from '../middleware/require-auth';
|
||||||
|
|
||||||
export interface DeviceTokenRecord {
|
export interface DeviceTokenRecord {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -14,6 +15,8 @@ export interface DeviceTokenRecord {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
|
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
|
||||||
|
|
||||||
@@ -21,12 +24,20 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
|
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userId !== req.authUserId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
await upsertDeviceToken({ userId, platform, token });
|
await upsertDeviceToken({ userId, platform, token });
|
||||||
|
|
||||||
res.status(201).json({ ok: true });
|
res.status(201).json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:userId', async (req, res) => {
|
router.get('/:userId', async (req, res) => {
|
||||||
|
if (req.params.userId !== req.authUserId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
const records = await listDeviceTokensForUser(req.params.userId);
|
const records = await listDeviceTokensForUser(req.params.userId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -40,6 +51,10 @@ router.get('/:userId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:userId/dispatch', async (req, res) => {
|
router.post('/:userId/dispatch', async (req, res) => {
|
||||||
|
if (req.params.userId !== req.authUserId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
const { title, body, data } = req.body as {
|
const { title, body, data } = req.body as {
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
@@ -7,20 +7,29 @@ import {
|
|||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyUser } from '../websocket/broadcast';
|
import { notifyUser } from '../websocket/broadcast';
|
||||||
import { resolveServerPermission } from '../services/server-permissions.service';
|
import { resolveServerPermission } from '../services/server-permissions.service';
|
||||||
|
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', requireAuth, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId, status } = req.body;
|
const { ownerId, status } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
const request = await getJoinRequestById(id);
|
const request = await getJoinRequestById(id);
|
||||||
|
|
||||||
if (!request)
|
if (!request)
|
||||||
return res.status(404).json({ error: 'Request not found' });
|
return res.status(404).json({ error: 'Request not found' });
|
||||||
|
|
||||||
|
if (ownerId && ownerId !== authenticatedUserId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'ownerId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const server = await getServerById(request.serverId);
|
const server = await getServerById(request.serverId);
|
||||||
|
|
||||||
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
if (!server || !resolveServerPermission(server, authenticatedUserId, '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']);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
upsertPluginRequirement
|
upsertPluginRequirement
|
||||||
} from '../services/plugin-support.service';
|
} from '../services/plugin-support.service';
|
||||||
import { broadcastToServer } from '../websocket/broadcast';
|
import { broadcastToServer } from '../websocket/broadcast';
|
||||||
|
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -21,8 +22,28 @@ function sendPluginSupportError(error: unknown, res: Response): void {
|
|||||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function readActorUserId(value: unknown): string {
|
type AuthenticatedRequest = Parameters<typeof getAuthenticatedUserId>[0] & { body: { actorUserId?: unknown } };
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
|
function readOptionalActorUserId(req: AuthenticatedRequest, res: Response): string | null {
|
||||||
|
let authenticatedUserId: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof req.body.actorUserId === 'string' && req.body.actorUserId.trim() && req.body.actorUserId !== authenticatedUserId) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'actorUserId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authenticatedUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
|
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
|
||||||
@@ -43,12 +64,17 @@ router.get('/:serverId/plugins', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
router.put('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
|
||||||
const { serverId, pluginId } = req.params;
|
const { serverId, pluginId } = req.params;
|
||||||
|
const actorUserId = readOptionalActorUserId(req, res);
|
||||||
|
|
||||||
|
if (!actorUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requirement = await upsertPluginRequirement({
|
const requirement = await upsertPluginRequirement({
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
actorUserId,
|
||||||
installUrl: req.body.installUrl,
|
installUrl: req.body.installUrl,
|
||||||
manifest: req.body.manifest,
|
manifest: req.body.manifest,
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -66,12 +92,17 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
router.delete('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
|
||||||
const { serverId, pluginId } = req.params;
|
const { serverId, pluginId } = req.params;
|
||||||
|
const actorUserId = readOptionalActorUserId(req, res);
|
||||||
|
|
||||||
|
if (!actorUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deletePluginRequirement({
|
await deletePluginRequirement({
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
actorUserId,
|
||||||
pluginId,
|
pluginId,
|
||||||
serverId
|
serverId
|
||||||
});
|
});
|
||||||
@@ -83,12 +114,17 @@ router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
router.put('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
|
||||||
const { serverId, pluginId, eventName } = req.params;
|
const { serverId, pluginId, eventName } = req.params;
|
||||||
|
const actorUserId = readOptionalActorUserId(req, res);
|
||||||
|
|
||||||
|
if (!actorUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventDefinition = await upsertPluginEventDefinition({
|
const eventDefinition = await upsertPluginEventDefinition({
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
actorUserId,
|
||||||
direction: req.body.direction,
|
direction: req.body.direction,
|
||||||
eventName,
|
eventName,
|
||||||
maxPayloadBytes: req.body.maxPayloadBytes,
|
maxPayloadBytes: req.body.maxPayloadBytes,
|
||||||
@@ -106,12 +142,17 @@ router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
router.delete('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
|
||||||
const { serverId, pluginId, eventName } = req.params;
|
const { serverId, pluginId, eventName } = req.params;
|
||||||
|
const actorUserId = readOptionalActorUserId(req, res);
|
||||||
|
|
||||||
|
if (!actorUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deletePluginEventDefinition({
|
await deletePluginEventDefinition({
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
actorUserId,
|
||||||
eventName,
|
eventName,
|
||||||
pluginId,
|
pluginId,
|
||||||
serverId
|
serverId
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
canModerateServerMember,
|
canModerateServerMember,
|
||||||
resolveServerPermission
|
resolveServerPermission
|
||||||
} from '../services/server-permissions.service';
|
} from '../services/server-permissions.service';
|
||||||
|
import {
|
||||||
|
getAuthenticatedUserId,
|
||||||
|
requireAuth,
|
||||||
|
rejectSpoofedUserId
|
||||||
|
} from '../middleware/require-auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -185,7 +190,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
|
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
const {
|
const {
|
||||||
id: clientId,
|
id: clientId,
|
||||||
name,
|
name,
|
||||||
@@ -204,12 +209,17 @@ router.post('/', async (req, res) => {
|
|||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
|
||||||
|
if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||||
const passwordHash = passwordHashForInput(password);
|
const passwordHash = passwordHashForInput(password);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
id: clientId || uuidv4(),
|
id: clientId || uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
ownerId,
|
ownerId: authenticatedOwnerId,
|
||||||
ownerPublicKey,
|
ownerPublicKey,
|
||||||
hasPassword: !!passwordHash,
|
hasPassword: !!passwordHash,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -225,12 +235,12 @@ router.post('/', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
await ensureServerMembership(server.id, ownerId);
|
await ensureServerMembership(server.id, authenticatedOwnerId);
|
||||||
|
|
||||||
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', requireAuth, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const {
|
const {
|
||||||
currentOwnerId,
|
currentOwnerId,
|
||||||
@@ -242,13 +252,16 @@ router.put('/:id', async (req, res) => {
|
|||||||
...updates
|
...updates
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (!authenticatedOwnerId) {
|
if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) {
|
||||||
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
return res.status(400).json({
|
||||||
|
error: 'currentOwnerId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||||
@@ -276,18 +289,22 @@ router.put('/:id', async (req, res) => {
|
|||||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/join', async (req, res) => {
|
router.post('/:id/join', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { userId, password, inviteId } = req.body;
|
const { userId, password, inviteId } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
|
|
||||||
if (!userId) {
|
if (userId && userId !== authenticatedUserId) {
|
||||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
return res.status(400).json({
|
||||||
|
error: 'userId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await joinServerWithAccess({
|
const result = await joinServerWithAccess({
|
||||||
serverId,
|
serverId,
|
||||||
userId: String(userId),
|
userId: authenticatedUserId,
|
||||||
password: typeof password === 'string' ? password : undefined,
|
password: typeof password === 'string' ? password : undefined,
|
||||||
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||||
});
|
});
|
||||||
@@ -305,12 +322,16 @@ router.post('/:id/join', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/invites', async (req, res) => {
|
router.post('/:id/invites', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { requesterUserId, requesterDisplayName } = req.body;
|
const { requesterUserId, requesterDisplayName } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
|
|
||||||
if (!requesterUserId) {
|
if (requesterUserId && requesterUserId !== authenticatedUserId) {
|
||||||
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
return res.status(400).json({
|
||||||
|
error: 'requesterUserId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
@@ -322,7 +343,7 @@ router.post('/:id/invites', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const invite = await createServerInvite(
|
const invite = await createServerInvite(
|
||||||
serverId,
|
serverId,
|
||||||
String(requesterUserId),
|
authenticatedUserId,
|
||||||
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -332,9 +353,10 @@ router.post('/:id/invites', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/moderation/kick', async (req, res) => {
|
router.post('/:id/moderation/kick', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, targetUserId } = req.body;
|
const { actorUserId, targetUserId } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -345,7 +367,14 @@ 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'actorUserId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'kickMembers')) {
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,9 +383,10 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/moderation/ban', async (req, res) => {
|
router.post('/:id/moderation/ban', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -367,7 +397,14 @@ 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'actorUserId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'banMembers')) {
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +412,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
serverId,
|
serverId,
|
||||||
userId: String(targetUserId),
|
userId: String(targetUserId),
|
||||||
banId: typeof banId === 'string' ? banId : undefined,
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
bannedBy: String(actorUserId || ''),
|
bannedBy: authenticatedUserId,
|
||||||
displayName: typeof displayName === 'string' ? displayName : undefined,
|
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||||
reason: typeof reason === 'string' ? reason : undefined,
|
reason: typeof reason === 'string' ? reason : undefined,
|
||||||
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||||
@@ -384,16 +421,24 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/moderation/unban', async (req, res) => {
|
router.post('/:id/moderation/unban', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, banId, targetUserId } = req.body;
|
const { actorUserId, banId, targetUserId } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
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 (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'actorUserId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolveServerPermission(server, authenticatedUserId, 'manageBans')) {
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,25 +451,29 @@ router.post('/:id/moderation/unban', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/leave', async (req, res) => {
|
router.post('/:id/leave', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
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 (!userId) {
|
if (userId && userId !== authenticatedUserId) {
|
||||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
return res.status(400).json({
|
||||||
|
error: 'userId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await leaveServerUser(serverId, String(userId));
|
await leaveServerUser(serverId, authenticatedUserId);
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/heartbeat', async (req, res) => {
|
router.post('/:id/heartbeat', requireAuth, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { currentUsers } = req.body;
|
const { currentUsers } = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
@@ -442,30 +491,38 @@ router.post('/:id/heartbeat', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', requireAuth, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId } = req.body;
|
const { ownerId } = req.body;
|
||||||
|
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (existing.ownerId !== ownerId)
|
if (ownerId && ownerId !== authenticatedOwnerId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'ownerId must match the authenticated user',
|
||||||
|
errorCode: 'USER_ID_MISMATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.ownerId !== authenticatedOwnerId)
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
|
||||||
await deleteServer(id);
|
await deleteServer(id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id/requests', async (req, res) => {
|
router.get('/:id/requests', requireAuth, async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server)
|
if (!server)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (server.ownerId !== ownerId)
|
if (!resolveServerPermission(server, authenticatedUserId, 'manageServer'))
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
|
||||||
const requests = await getPendingRequestsForServer(serverId);
|
const requests = await getPendingRequestsForServer(serverId);
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { getUserByUsername, registerUser } from '../cqrs';
|
import {
|
||||||
|
getUserById,
|
||||||
|
getUserByUsername,
|
||||||
|
registerUser,
|
||||||
|
updateUserPasswordHash,
|
||||||
|
updateUserSigningPublicKey
|
||||||
|
} from '../cqrs';
|
||||||
|
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
|
||||||
|
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
|
||||||
|
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
function hashPassword(pw: string): string {
|
function buildAuthResponse(user: {
|
||||||
return crypto.createHash('sha256').update(pw)
|
id: string;
|
||||||
.digest('hex');
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
}, token: string, expiresAt: number) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
token,
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
@@ -24,23 +41,64 @@ router.post('/register', async (req, res) => {
|
|||||||
const user = {
|
const user = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
username,
|
username,
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: await hashPasswordForStorage(password),
|
||||||
displayName: displayName || username,
|
displayName: displayName || username,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
await registerUser(user);
|
await registerUser(user);
|
||||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
const session = await issueSessionToken(user.id);
|
||||||
|
|
||||||
|
res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
const user = await getUserByUsername(username);
|
const user = await getUserByUsername(username);
|
||||||
|
|
||||||
if (!user || user.passwordHash !== hashPassword(password))
|
if (!user || !(await verifyPassword(password, user.passwordHash)))
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
const upgradedHash = await hashPasswordForStorage(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (upgradedHash !== user.passwordHash) {
|
||||||
|
await updateUserPasswordHash(user.id, upgradedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await issueSessionToken(user.id);
|
||||||
|
|
||||||
|
res.json(buildAuthResponse(user, session.token, session.expiresAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/me/signing-key', requireAuth, async (req, res) => {
|
||||||
|
const { publicKeyJwk } = req.body;
|
||||||
|
const userId = getAuthenticatedUserId(req);
|
||||||
|
|
||||||
|
if (!publicKeyJwk || typeof publicKeyJwk !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Missing publicKeyJwk', errorCode: 'INVALID_SIGNING_KEY' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUserSigningPublicKey(userId, JSON.stringify(publicKeyJwk));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/signing-public-key', async (req, res) => {
|
||||||
|
const user = await getUserById(req.params.id);
|
||||||
|
|
||||||
|
if (!user?.signingPublicKey) {
|
||||||
|
return res.status(404).json({ error: 'Signing public key not found', errorCode: 'SIGNING_KEY_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ publicKeyJwk: JSON.parse(user.signingPublicKey) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', requireAuth, async (req, res) => {
|
||||||
|
if (req.authToken) {
|
||||||
|
await revokeSessionToken(req.authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
45
server/src/services/password-auth.service.spec.ts
Normal file
45
server/src/services/password-auth.service.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import {
|
||||||
|
hashPasswordForStorage,
|
||||||
|
isLegacySha256Hash,
|
||||||
|
verifyPassword
|
||||||
|
} from './password-auth.service';
|
||||||
|
|
||||||
|
describe('password-auth.service', () => {
|
||||||
|
it('stores bcrypt hashes for new passwords', async () => {
|
||||||
|
const hash = await hashPasswordForStorage('secret-pass');
|
||||||
|
|
||||||
|
expect(hash.startsWith('$2')).toBe(true);
|
||||||
|
expect(isLegacySha256Hash(hash)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies bcrypt passwords', async () => {
|
||||||
|
const hash = await hashPasswordForStorage('secret-pass');
|
||||||
|
|
||||||
|
await expect(verifyPassword('secret-pass', hash)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong-pass', hash)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies legacy sha256 passwords', async () => {
|
||||||
|
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
expect(isLegacySha256Hash(legacyHash)).toBe(true);
|
||||||
|
await expect(verifyPassword('legacy-pass', legacyHash)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong-pass', legacyHash)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rehashes legacy passwords to bcrypt after successful verification', async () => {
|
||||||
|
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
|
||||||
|
.digest('hex');
|
||||||
|
const upgraded = await hashPasswordForStorage('legacy-pass', legacyHash);
|
||||||
|
|
||||||
|
expect(upgraded.startsWith('$2')).toBe(true);
|
||||||
|
await expect(verifyPassword('legacy-pass', upgraded)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
server/src/services/password-auth.service.ts
Normal file
33
server/src/services/password-auth.service.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export function isLegacySha256Hash(passwordHash: string): boolean {
|
||||||
|
return /^[a-f0-9]{64}$/i.test(passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPasswordForStorage(
|
||||||
|
password: string,
|
||||||
|
existingHash?: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (existingHash && isLegacySha256Hash(existingHash)) {
|
||||||
|
const legacyMatches = crypto.createHash('sha256').update(password)
|
||||||
|
.digest('hex') === existingHash;
|
||||||
|
|
||||||
|
if (legacyMatches) {
|
||||||
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||||
|
if (isLegacySha256Hash(passwordHash)) {
|
||||||
|
return crypto.createHash('sha256').update(password)
|
||||||
|
.digest('hex') === passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bcrypt.compare(password, passwordHash);
|
||||||
|
}
|
||||||
@@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise<number>
|
|||||||
return await getMembershipRepository().count({ where: { serverId } });
|
return await getMembershipRepository().count({ where: { serverId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function usersShareServerMembership(userA: string, userB: string): Promise<boolean> {
|
||||||
|
if (userA === userB) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = getMembershipRepository();
|
||||||
|
const membershipsForA = await repo.find({
|
||||||
|
where: { userId: userA },
|
||||||
|
select: ['serverId']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (membershipsForA.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const membership of membershipsForA) {
|
||||||
|
const shared = await repo.findOne({
|
||||||
|
where: { serverId: membership.serverId, userId: userB }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shared) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
const repo = getMembershipRepository();
|
const repo = getMembershipRepository();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
110
server/src/services/session-auth.service.ts
Normal file
110
server/src/services/session-auth.service.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import { SessionTokenEntity } from '../entities/SessionTokenEntity';
|
||||||
|
import { getUserById } from '../cqrs';
|
||||||
|
import type { AuthUserPayload } from '../cqrs/types';
|
||||||
|
|
||||||
|
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface IssuedSessionToken {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
issuedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedSession {
|
||||||
|
token: string;
|
||||||
|
user: AuthUserPayload;
|
||||||
|
issuedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenRepository() {
|
||||||
|
return getDataSource().getRepository(SessionTokenEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTokenTtlMs(): number {
|
||||||
|
const configured = Number(process.env.SESSION_TOKEN_TTL_MS);
|
||||||
|
|
||||||
|
return Number.isFinite(configured) && configured > 0
|
||||||
|
? configured
|
||||||
|
: DEFAULT_TOKEN_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueSessionToken(userId: string): Promise<IssuedSessionToken> {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const expiresAt = issuedAt + getSessionTokenTtlMs();
|
||||||
|
const repo = getTokenRepository();
|
||||||
|
|
||||||
|
await repo.save(repo.create({
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { token, userId, issuedAt, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeSessionToken(token: string): Promise<AuthenticatedSession | null> {
|
||||||
|
const normalized = token.trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = getTokenRepository();
|
||||||
|
const record = await repo.findOne({ where: { token: normalized } });
|
||||||
|
|
||||||
|
if (!record || record.expiresAt < Date.now()) {
|
||||||
|
if (record) {
|
||||||
|
await repo.delete({ token: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(record.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
await repo.delete({ token: normalized });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: record.token,
|
||||||
|
user,
|
||||||
|
issuedAt: record.issuedAt,
|
||||||
|
expiresAt: record.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSessionToken(token: string): Promise<void> {
|
||||||
|
const normalized = token.trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getTokenRepository().delete({ token: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllSessionTokensForUser(userId: string): Promise<void> {
|
||||||
|
await getTokenRepository().delete({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredSessionTokens(): Promise<void> {
|
||||||
|
const repo = getTokenRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = await repo.createQueryBuilder('token')
|
||||||
|
.where('token.expiresAt < :now', { now })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (expired.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.remove(expired);
|
||||||
|
}
|
||||||
11
server/src/types/express-augmentation.ts
Normal file
11
server/src/types/express-augmentation.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { AuthUserPayload } from '../cqrs/types';
|
||||||
|
|
||||||
|
declare module 'express-serve-static-core' {
|
||||||
|
interface Request {
|
||||||
|
authUserId?: string;
|
||||||
|
authUser?: AuthUserPayload;
|
||||||
|
authToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
120
server/src/websocket/handler-auth.spec.ts
Normal file
120
server/src/websocket/handler-auth.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||||
|
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||||
|
usersShareServerMembership: vi.fn(async () => false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/session-auth.service', () => ({
|
||||||
|
consumeSessionToken: vi.fn(async (token: string) => {
|
||||||
|
if (token !== 'valid-token') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
createdAt: Date.now()
|
||||||
|
},
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(connectionId: string): ConnectedUser {
|
||||||
|
const ws = createMockWs();
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId: connectionId,
|
||||||
|
ws,
|
||||||
|
authenticated: false,
|
||||||
|
serverIds: new Set(),
|
||||||
|
displayName: 'Test User',
|
||||||
|
lastPong: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - authentication', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-identify messages until the connection is authenticated', async () => {
|
||||||
|
createConnectedUser('conn-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'typing', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const user = connectedUsers.get('conn-1');
|
||||||
|
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
const response = JSON.parse(sentMessages[0]) as { type: string };
|
||||||
|
|
||||||
|
expect(response.type).toBe('auth_required');
|
||||||
|
expect(user?.authenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects identify without a session token', async () => {
|
||||||
|
createConnectedUser('conn-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = connectedUsers.get('conn-1');
|
||||||
|
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
const response = JSON.parse(sentMessages[0]) as { type: string; code: string };
|
||||||
|
|
||||||
|
expect(response.type).toBe('auth_error');
|
||||||
|
expect(response.code).toBe('MISSING_TOKEN');
|
||||||
|
expect(user?.authenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds identify to the authenticated user id from the token', async () => {
|
||||||
|
createConnectedUser('conn-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
token: 'valid-token',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = connectedUsers.get('conn-1');
|
||||||
|
|
||||||
|
expect(user?.authenticated).toBe(true);
|
||||||
|
expect(user?.oderId).toBe('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,7 @@ function createConnectedUser(
|
|||||||
displayName: `User ${oderId}`,
|
displayName: `User ${oderId}`,
|
||||||
lastPong: Date.now(),
|
lastPong: Date.now(),
|
||||||
oderId,
|
oderId,
|
||||||
|
authenticated: true,
|
||||||
serverIds: new Set(),
|
serverIds: new Set(),
|
||||||
ws: createMockWs(),
|
ws: createMockWs(),
|
||||||
...overrides
|
...overrides
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ vi.mock('../services/server-access.service', () => ({
|
|||||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let authenticatedUserId = 'user-1';
|
||||||
|
|
||||||
|
vi.mock('../services/session-auth.service', () => ({
|
||||||
|
consumeSessionToken: vi.fn(async (token: string) => {
|
||||||
|
if (token !== 'test-token') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: authenticatedUserId,
|
||||||
|
username: 'test-user',
|
||||||
|
displayName: 'Test User',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
createdAt: Date.now()
|
||||||
|
},
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||||
|
requirements: [],
|
||||||
|
eventDefinitions: []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal mock WebSocket that records sent messages.
|
* Minimal mock WebSocket that records sent messages.
|
||||||
*/
|
*/
|
||||||
@@ -38,6 +73,7 @@ function createConnectedUser(
|
|||||||
const user: ConnectedUser = {
|
const user: ConnectedUser = {
|
||||||
oderId,
|
oderId,
|
||||||
ws,
|
ws,
|
||||||
|
authenticated: true,
|
||||||
serverIds: new Set(),
|
serverIds: new Set(),
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
lastPong: Date.now(),
|
lastPong: Date.now(),
|
||||||
@@ -168,7 +204,8 @@ describe('server websocket handler - status_update', () => {
|
|||||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
// Identify first (required for handler)
|
// Identify first (required for handler)
|
||||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
authenticatedUserId = 'user-1';
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
// user-2 joins server -> should receive server_users with user-1's status
|
// user-2 joins server -> should receive server_users with user-1's status
|
||||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
@@ -201,7 +238,8 @@ describe('server websocket handler - user_joined includes status', () => {
|
|||||||
getRequiredConnectedUser('conn-1').status = 'busy';
|
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||||
|
|
||||||
// Identify user-1
|
// Identify user-1
|
||||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
authenticatedUserId = 'user-1';
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
@@ -237,8 +275,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
|||||||
bob.serverIds.add('server-1');
|
bob.serverIds.add('server-1');
|
||||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||||
|
|
||||||
|
authenticatedUserId = 'user-1';
|
||||||
await handleWebSocketMessage('conn-1', {
|
await handleWebSocketMessage('conn-1', {
|
||||||
type: 'identify',
|
type: 'identify',
|
||||||
|
token: 'test-token',
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
displayName: 'Alice Updated',
|
displayName: 'Alice Updated',
|
||||||
description: 'Updated bio',
|
description: 'Updated bio',
|
||||||
@@ -261,8 +301,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
|||||||
alice.serverIds.add('server-1');
|
alice.serverIds.add('server-1');
|
||||||
bob.serverIds.add('server-1');
|
bob.serverIds.add('server-1');
|
||||||
|
|
||||||
|
authenticatedUserId = 'user-1';
|
||||||
await handleWebSocketMessage('conn-1', {
|
await handleWebSocketMessage('conn-1', {
|
||||||
type: 'identify',
|
type: 'identify',
|
||||||
|
token: 'test-token',
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
description: 'Alice bio',
|
description: 'Alice bio',
|
||||||
@@ -291,8 +333,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
|||||||
alice.serverIds.add('server-1');
|
alice.serverIds.add('server-1');
|
||||||
bob.serverIds.add('server-1');
|
bob.serverIds.add('server-1');
|
||||||
|
|
||||||
|
authenticatedUserId = 'user-1';
|
||||||
await handleWebSocketMessage('conn-1', {
|
await handleWebSocketMessage('conn-1', {
|
||||||
type: 'identify',
|
type: 'identify',
|
||||||
|
token: 'test-token',
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
homeSignalServerUrl: 'http://signal.example.com:3001/'
|
homeSignalServerUrl: 'http://signal.example.com:3001/'
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
getUniqueUsersInServer,
|
getUniqueUsersInServer,
|
||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import {
|
||||||
|
authorizeWebSocketJoin,
|
||||||
|
findServerMembership,
|
||||||
|
usersShareServerMembership
|
||||||
|
} from '../services/server-access.service';
|
||||||
|
import { consumeSessionToken } from '../services/session-auth.service';
|
||||||
import {
|
import {
|
||||||
getPluginRequirementsSnapshot,
|
getPluginRequirementsSnapshot,
|
||||||
PluginSupportError,
|
PluginSupportError,
|
||||||
@@ -131,8 +136,67 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
const DIRECT_SIGNALING_TYPES = new Set([
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
'direct-message',
|
||||||
|
'direct-message-status',
|
||||||
|
'direct-message-mutation',
|
||||||
|
'direct-message-typing',
|
||||||
|
'direct-message-sync-request',
|
||||||
|
'direct-message-sync',
|
||||||
|
'direct-call'
|
||||||
|
]);
|
||||||
|
const SERVER_SCOPED_SIGNALING_TYPES = new Set([
|
||||||
|
'server_icon_peer_request',
|
||||||
|
'server_icon_peer_data',
|
||||||
|
'server_icon_available',
|
||||||
|
'server_icon_sync_request'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sendAuthRequired(user: ConnectedUser): void {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'auth_required',
|
||||||
|
message: 'identify with a valid session token before sending messages'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
|
const token = typeof message['token'] === 'string' ? message['token'].trim() : '';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'auth_error',
|
||||||
|
code: 'MISSING_TOKEN',
|
||||||
|
message: 'identify requires a session token'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await consumeSessionToken(token);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'auth_error',
|
||||||
|
code: 'INVALID_TOKEN',
|
||||||
|
message: 'invalid or expired session token'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimedOderId = readMessageId(message['oderId']);
|
||||||
|
|
||||||
|
if (claimedOderId && claimedOderId !== session.user.id) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'auth_error',
|
||||||
|
code: 'USER_ID_MISMATCH',
|
||||||
|
message: 'oderId must match the authenticated user'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOderId = session.user.id;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||||
const previousDescription = user.description;
|
const previousDescription = user.description;
|
||||||
@@ -140,6 +204,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
|
user.authenticated = true;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||||
@@ -277,11 +342,45 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
async function canForwardRtcMessage(user: ConnectedUser, message: WsMessage, targetUserId: string): Promise<boolean> {
|
||||||
|
if (!targetUserId || targetUserId === user.oderId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DIRECT_SIGNALING_TYPES.has(message.type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SERVER_SCOPED_SIGNALING_TYPES.has(message.type)) {
|
||||||
|
const serverId = readMessageId(message['serverId']);
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderMembership = await findServerMembership(serverId, user.oderId);
|
||||||
|
const targetMembership = await findServerMembership(serverId, targetUserId);
|
||||||
|
|
||||||
|
return !!senderMembership && !!targetMembership;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'offer' || message.type === 'answer' || message.type === 'ice_candidate') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usersShareServerMembership(user.oderId, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||||
const targetUserId = readMessageId(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}`);
|
||||||
|
|
||||||
|
if (!(await canForwardRtcMessage(user, message, targetUserId))) {
|
||||||
|
console.log(`Blocked ${message.type} relay from ${user.oderId} to ${targetUserId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetUser = findUserByOderId(targetUserId);
|
const targetUser = findUserByOderId(targetUserId);
|
||||||
|
|
||||||
if (targetUser) {
|
if (targetUser) {
|
||||||
@@ -482,13 +581,18 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
user.lastPong = Date.now();
|
user.lastPong = Date.now();
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
if (!user.authenticated && message.type !== 'identify' && message.type !== 'keepalive') {
|
||||||
|
sendAuthRequired(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'keepalive':
|
case 'keepalive':
|
||||||
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
|
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'identify':
|
case 'identify':
|
||||||
handleIdentify(user, message, connectionId);
|
await handleIdentify(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
@@ -515,7 +619,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
case 'direct-call':
|
case 'direct-call':
|
||||||
case 'server_icon_peer_request':
|
case 'server_icon_peer_request':
|
||||||
case 'server_icon_peer_data':
|
case 'server_icon_peer_data':
|
||||||
forwardRtcMessage(user, message);
|
await forwardRtcMessage(user, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'chat_message':
|
case 'chat_message':
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
|||||||
const connectionId = uuidv4();
|
const connectionId = uuidv4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
connectedUsers.set(connectionId, {
|
||||||
|
oderId: connectionId,
|
||||||
|
ws,
|
||||||
|
authenticated: false,
|
||||||
|
serverIds: new Set(),
|
||||||
|
lastPong: now
|
||||||
|
});
|
||||||
|
|
||||||
ws.on('pong', () => {
|
ws.on('pong', () => {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws';
|
|||||||
export interface ConnectedUser {
|
export interface ConnectedUser {
|
||||||
oderId: string;
|
oderId: string;
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
|
authenticated: boolean;
|
||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"prepareStateFailed": "Failed to prepare local user state.",
|
"prepareStateFailed": "Failed to prepare local user state.",
|
||||||
"noCurrentUser": "No current user"
|
"noCurrentUser": "No current user",
|
||||||
|
"sessionExpired": "Your session expired. Please sign in again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"prepareStateFailed": "Failed to prepare local user state.",
|
"prepareStateFailed": "Failed to prepare local user state.",
|
||||||
"noCurrentUser": "No current user"
|
"noCurrentUser": "No current user",
|
||||||
|
"sessionExpired": "Your session expired. Please sign in again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"call": {
|
"call": {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
isDevMode
|
isDevMode
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { authTokenInterceptor } from './domains/authentication/infrastructure/auth-token.interceptor';
|
||||||
import { provideTranslateService } from '@ngx-translate/core';
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
import { provideStore } from '@ngrx/store';
|
import { provideStore } from '@ngrx/store';
|
||||||
import { provideEffects } from '@ngrx/effects';
|
import { provideEffects } from '@ngrx/effects';
|
||||||
@@ -32,7 +33,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(),
|
provideHttpClient(withInterceptors([authTokenInterceptor])),
|
||||||
provideTranslateService({
|
provideTranslateService({
|
||||||
fallbackLang: DEFAULT_APP_LOCALE,
|
fallbackLang: DEFAULT_APP_LOCALE,
|
||||||
lang: DEFAULT_APP_LOCALE
|
lang: DEFAULT_APP_LOCALE
|
||||||
|
|||||||
@@ -294,9 +294,10 @@ export interface ElectronApi {
|
|||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string | null>;
|
||||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||||
getFileSize: (filePath: string) => Promise<number>;
|
getFileSize: (filePath: string) => Promise<number | null>;
|
||||||
|
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
|
|||||||
@@ -340,7 +340,12 @@ export class AttachmentTransferService {
|
|||||||
!messageId || !fileId ||
|
!messageId || !fileId ||
|
||||||
typeof index !== 'number' ||
|
typeof index !== 'number' ||
|
||||||
typeof total !== 'number' ||
|
typeof total !== 'number' ||
|
||||||
typeof data !== 'string'
|
typeof data !== 'string' ||
|
||||||
|
!Number.isInteger(index) ||
|
||||||
|
!Number.isInteger(total) ||
|
||||||
|
total <= 0 ||
|
||||||
|
index < 0 ||
|
||||||
|
index >= total
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -351,6 +356,14 @@ export class AttachmentTransferService {
|
|||||||
if (!attachment)
|
if (!attachment)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > attachment.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shouldReceiveToDisk(attachment)) {
|
if (this.shouldReceiveToDisk(attachment)) {
|
||||||
this.enqueueDiskFileChunk(attachment, {
|
this.enqueueDiskFileChunk(attachment, {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { Attachment } from '../../domain/models/attachment.model';
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
import {
|
import {
|
||||||
|
isAllowedAttachmentStoredPath,
|
||||||
resolveAttachmentStorageBucket,
|
resolveAttachmentStorageBucket,
|
||||||
resolveAttachmentStoredFilename,
|
resolveAttachmentStoredFilename,
|
||||||
sanitizeAttachmentRoomName
|
sanitizeAttachmentRoomName
|
||||||
@@ -74,7 +75,9 @@ export class AttachmentStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
|
const safeFilename = resolveAttachmentStoredFilename('legacy', filename);
|
||||||
|
|
||||||
|
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${safeFilename}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(filePath: string): Promise<string | null> {
|
async readFile(filePath: string): Promise<string | null> {
|
||||||
@@ -234,13 +237,14 @@ export class AttachmentStorageService {
|
|||||||
|
|
||||||
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
const appDataPath = await this.resolveAppDataPath();
|
||||||
|
|
||||||
if (!electronApi) {
|
if (!electronApi || !appDataPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const candidatePath of candidates) {
|
for (const candidatePath of candidates) {
|
||||||
if (!candidatePath) {
|
if (!candidatePath || !isAllowedAttachmentStoredPath(candidatePath, appDataPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import { isAllowedAttachmentStoredPath, resolveAttachmentStoredFilename } from './attachment-storage.util';
|
||||||
|
|
||||||
|
describe('attachment-storage.util', () => {
|
||||||
|
it('allows attachment paths under server and direct-messages roots', () => {
|
||||||
|
const appDataPath = '/home/user/.config/metoyou';
|
||||||
|
|
||||||
|
expect(isAllowedAttachmentStoredPath(`${appDataPath}/server/room-1/image/file.png`, appDataPath)).toBe(true);
|
||||||
|
expect(isAllowedAttachmentStoredPath(`${appDataPath}/direct-messages/dm-1/files/file.bin`, appDataPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside attachment roots', () => {
|
||||||
|
const appDataPath = '/home/user/.config/metoyou';
|
||||||
|
|
||||||
|
expect(isAllowedAttachmentStoredPath('/etc/passwd', appDataPath)).toBe(false);
|
||||||
|
expect(isAllowedAttachmentStoredPath(`${appDataPath}/plugins/evil.js`, appDataPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes legacy filenames to basename-only storage names', () => {
|
||||||
|
expect(resolveAttachmentStoredFilename('legacy', '../../escape.txt')).toBe('legacy.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,21 @@ export function resolveAttachmentStoredFilename(attachmentId: string, filename:
|
|||||||
: `${sanitizedAttachmentId}${sanitizedExtension}`;
|
: `${sanitizedAttachmentId}${sanitizedExtension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAllowedAttachmentStoredPath(candidatePath: string, appDataPath: string): boolean {
|
||||||
|
const normalizedCandidate = candidatePath.trim().replace(/\\/g, '/');
|
||||||
|
const normalizedRoot = appDataPath.trim().replace(/\\/g, '/')
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!normalizedCandidate.startsWith(`${normalizedRoot}/`)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = normalizedCandidate.slice(normalizedRoot.length + 1);
|
||||||
|
|
||||||
|
return relativePath.startsWith('server/')
|
||||||
|
|| relativePath.startsWith('direct-messages/');
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
|
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
|
||||||
if (mime.startsWith('video/')) {
|
if (mime.startsWith('video/')) {
|
||||||
return 'video';
|
return 'video';
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach
|
||||||
|
} from 'vitest';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
|
||||||
|
describe('AuthTokenStoreService', () => {
|
||||||
|
let service: AuthTokenStoreService;
|
||||||
|
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
service = new AuthTokenStoreService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports whether any non-expired token remains', () => {
|
||||||
|
expect(service.hasAnyValidToken()).toBe(false);
|
||||||
|
|
||||||
|
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
|
||||||
|
|
||||||
|
expect(service.hasAnyValidToken()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and resolves tokens by signaling server url', () => {
|
||||||
|
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
|
||||||
|
|
||||||
|
expect(service.getToken('http://localhost:3001')).toBe('token-abc');
|
||||||
|
expect(service.findTokenForApiUrl('http://localhost:3001/api/servers')).toBe('token-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears expired tokens on read', () => {
|
||||||
|
service.setToken('http://localhost:3001', 'expired-token', Date.now() - 1);
|
||||||
|
|
||||||
|
expect(service.getToken('http://localhost:3001')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
interface StoredAuthToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'metoyou.authTokens';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthTokenStoreService {
|
||||||
|
setToken(serverUrl: string, token: string, expiresAt: number): void {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const store = this.readStore();
|
||||||
|
|
||||||
|
store[normalizedUrl] = { token, expiresAt };
|
||||||
|
this.writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(serverUrl: string): string | null {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const entry = this.readStore()[normalizedUrl];
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.clearToken(serverUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearToken(serverUrl: string): void {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const store = this.readStore();
|
||||||
|
const nextStore = Object.fromEntries(
|
||||||
|
Object.entries(store).filter(([key]) => key !== normalizedUrl)
|
||||||
|
) as Record<string, StoredAuthToken>;
|
||||||
|
|
||||||
|
this.writeStore(nextStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyValidToken(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return Object.values(this.readStore()).some((entry) => entry.expiresAt > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
findTokenForApiUrl(apiUrl: string): string | null {
|
||||||
|
const normalizedApiUrl = apiUrl.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
for (const [serverUrl, entry] of Object.entries(this.readStore())) {
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedApiUrl === serverUrl || normalizedApiUrl.startsWith(`${serverUrl}/`)) {
|
||||||
|
return entry.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStore(): Record<string, StoredAuthToken> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, StoredAuthToken>;
|
||||||
|
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStore(store: Record<string, StoredAuthToken>): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServerUrl(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory';
|
import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import type { LoginResponse } from '../../domain/models/authentication.model';
|
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
import { MessageSigningService } from './message-signing.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles user authentication (login and registration) against a
|
* Handles user authentication (login and registration) against a
|
||||||
@@ -17,6 +19,8 @@ import type { LoginResponse } from '../../domain/models/authentication.model';
|
|||||||
export class AuthenticationService {
|
export class AuthenticationService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
private readonly messageSigning = inject(MessageSigningService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the API base URL for the given server.
|
* Resolve the API base URL for the given server.
|
||||||
@@ -25,6 +29,14 @@ export class AuthenticationService {
|
|||||||
* currently active endpoint is used.
|
* currently active endpoint is used.
|
||||||
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
||||||
*/
|
*/
|
||||||
|
private resolveServerUrl(serverId?: string): string {
|
||||||
|
return this.endpointFor(serverId).replace(/\/api$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
||||||
|
this.authTokenStore.setToken(this.resolveServerUrl(serverId), response.token, response.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
private endpointFor(serverId?: string): string {
|
private endpointFor(serverId?: string): string {
|
||||||
let endpoint: ServerEndpoint | undefined;
|
let endpoint: ServerEndpoint | undefined;
|
||||||
|
|
||||||
@@ -63,7 +75,12 @@ export class AuthenticationService {
|
|||||||
username: params.username,
|
username: params.username,
|
||||||
password: params.password,
|
password: params.password,
|
||||||
displayName: params.displayName
|
displayName: params.displayName
|
||||||
});
|
}).pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.persistSessionToken(params.serverId, response);
|
||||||
|
void this.messageSigning.registerSigningPublicKeyIfNeeded().catch(() => {});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +102,11 @@ export class AuthenticationService {
|
|||||||
return this.http.post<LoginResponse>(url, {
|
return this.http.post<LoginResponse>(url, {
|
||||||
username: params.username,
|
username: params.username,
|
||||||
password: params.password
|
password: params.password
|
||||||
});
|
}).pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.persistSessionToken(params.serverId, response);
|
||||||
|
void this.messageSigning.registerSigningPublicKeyIfNeeded().catch(() => {});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import type { MessageRevision } from '../../../../shared-kernel';
|
||||||
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'metoyou.messageSigningKeyPair';
|
||||||
|
|
||||||
|
interface StoredSigningKeyPair {
|
||||||
|
publicKeyJwk: JsonWebKey;
|
||||||
|
privateKeyJwk: JsonWebKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MessageSigningService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
private keyPairPromise: Promise<CryptoKeyPair> | null = null;
|
||||||
|
|
||||||
|
async ensureSigningKeyPair(): Promise<CryptoKeyPair> {
|
||||||
|
if (!this.keyPairPromise) {
|
||||||
|
this.keyPairPromise = this.loadOrCreateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.keyPairPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicKeyJwk(): Promise<JsonWebKey> {
|
||||||
|
const keyPair = await this.ensureSigningKeyPair();
|
||||||
|
|
||||||
|
return await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSigningPublicKeyIfNeeded(): Promise<void> {
|
||||||
|
const activeServer = this.serverDirectory.activeServer();
|
||||||
|
|
||||||
|
if (!activeServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyJwk = await this.getPublicKeyJwk();
|
||||||
|
const apiBase = `${activeServer.url}/api`;
|
||||||
|
|
||||||
|
await firstValueFrom(this.http.put(`${apiBase}/users/me/signing-key`, { publicKeyJwk }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async signRevision(revision: MessageRevision): Promise<string> {
|
||||||
|
const keyPair = await this.ensureSigningKeyPair();
|
||||||
|
const payload = this.canonicalRevisionPayload(revision);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
|
||||||
|
const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, digest);
|
||||||
|
|
||||||
|
return this.bufferToBase64Url(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSigningPublicKey(userId: string): Promise<JsonWebKey | null> {
|
||||||
|
const activeServer = this.serverDirectory.activeServer();
|
||||||
|
|
||||||
|
if (!activeServer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<{ publicKeyJwk: JsonWebKey }>(`${activeServer.url}/api/users/${encodeURIComponent(userId)}/signing-public-key`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.publicKeyJwk;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyRevisionSignature(
|
||||||
|
revision: MessageRevision,
|
||||||
|
publicKeyJwk: JsonWebKey
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!revision.signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
publicKeyJwk,
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
const payload = this.canonicalRevisionPayload(revision);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
|
||||||
|
const signature = this.base64UrlToBuffer(revision.signature);
|
||||||
|
|
||||||
|
return await crypto.subtle.verify('Ed25519', publicKey, signature, digest);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOrCreateKeyPair(): Promise<CryptoKeyPair> {
|
||||||
|
const stored = this.readStoredKeyPair();
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
const [publicKey, privateKey] = await Promise.all([
|
||||||
|
crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']),
|
||||||
|
crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { publicKey, privateKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = await crypto.subtle.generateKey(
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
true,
|
||||||
|
['sign', 'verify']
|
||||||
|
);
|
||||||
|
const [publicKeyJwk, privateKeyJwk] = await Promise.all([
|
||||||
|
crypto.subtle.exportKey('jwk', generated.publicKey),
|
||||||
|
crypto.subtle.exportKey('jwk', generated.privateKey)
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
||||||
|
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canonicalRevisionPayload(revision: MessageRevision): string {
|
||||||
|
const { signature: _signature, ...unsigned } = revision;
|
||||||
|
|
||||||
|
return JSON.stringify(unsigned, Object.keys(unsigned).sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoredKeyPair(): StoredSigningKeyPair | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw) as StoredSigningKeyPair;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStoredKeyPair(pair: StoredSigningKeyPair): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(pair));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64UrlToBuffer(value: string): ArrayBuffer {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
SESSION_EXPIRED_ERROR_CODE,
|
||||||
|
collectSessionTokenLookupUrls,
|
||||||
|
hasValidPersistedSession
|
||||||
|
} from './auth-session.rules';
|
||||||
|
|
||||||
|
describe('auth-session.rules', () => {
|
||||||
|
const user = {
|
||||||
|
homeSignalServerUrl: 'https://signal.example.com'
|
||||||
|
} as Pick<User, 'homeSignalServerUrl'>;
|
||||||
|
|
||||||
|
it('collects home and active server urls without duplicates', () => {
|
||||||
|
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual([
|
||||||
|
'https://signal.example.com'
|
||||||
|
]);
|
||||||
|
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual([
|
||||||
|
'http://localhost:3001',
|
||||||
|
'https://signal.example.com'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a valid token for a known server url', () => {
|
||||||
|
const getToken = (url: string) => (url === 'https://signal.example.com' ? 'token-1' : null);
|
||||||
|
|
||||||
|
expect(hasValidPersistedSession(user, 'https://signal.example.com', getToken)).toBe(true);
|
||||||
|
expect(hasValidPersistedSession(user, 'http://localhost:3001', getToken)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects persisted users without any valid session token', () => {
|
||||||
|
expect(hasValidPersistedSession(user, 'https://signal.example.com', () => null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to any stored token when preferred urls are missing', () => {
|
||||||
|
expect(
|
||||||
|
hasValidPersistedSession(
|
||||||
|
{} as Pick<User, 'homeSignalServerUrl'>,
|
||||||
|
null,
|
||||||
|
() => null,
|
||||||
|
() => true
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports a stable session-expired error code', () => {
|
||||||
|
expect(SESSION_EXPIRED_ERROR_CODE).toBe('SESSION_EXPIRED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
export const SESSION_EXPIRED_ERROR_CODE = 'SESSION_EXPIRED';
|
||||||
|
|
||||||
|
function normalizeServerUrl(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSessionTokenLookupUrls(
|
||||||
|
user: Pick<User, 'homeSignalServerUrl'>,
|
||||||
|
activeServerUrl?: string | null
|
||||||
|
): string[] {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
if (activeServerUrl?.trim()) {
|
||||||
|
urls.add(normalizeServerUrl(activeServerUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.homeSignalServerUrl?.trim()) {
|
||||||
|
urls.add(normalizeServerUrl(user.homeSignalServerUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasValidSessionTokenForUrls(
|
||||||
|
urls: readonly string[],
|
||||||
|
getToken: (serverUrl: string) => string | null
|
||||||
|
): boolean {
|
||||||
|
return urls.some((url) => !!getToken(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasValidPersistedSession(
|
||||||
|
user: Pick<User, 'homeSignalServerUrl'>,
|
||||||
|
activeServerUrl: string | null | undefined,
|
||||||
|
getToken: (serverUrl: string) => string | null,
|
||||||
|
hasAnyValidToken?: () => boolean
|
||||||
|
): boolean {
|
||||||
|
const preferredUrls = collectSessionTokenLookupUrls(user, activeServerUrl);
|
||||||
|
|
||||||
|
if (preferredUrls.length > 0 && hasValidSessionTokenForUrls(preferredUrls, getToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAnyValidToken?.() ?? false;
|
||||||
|
}
|
||||||
@@ -8,4 +8,8 @@ export interface LoginResponse {
|
|||||||
username: string;
|
username: string;
|
||||||
/** Human-readable display name. */
|
/** Human-readable display name. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Opaque session token for authenticated API and WebSocket identify calls. */
|
||||||
|
token: string;
|
||||||
|
/** Unix timestamp (ms) when the session token expires. */
|
||||||
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
|
export * from './application/services/auth-token-store.service';
|
||||||
export * from './domain/models/authentication.model';
|
export * from './domain/models/authentication.model';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { AuthTokenStoreService } from '../application/services/auth-token-store.service';
|
||||||
|
|
||||||
|
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (!req.url.includes('/api/')) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
const token = authTokenStore.findTokenForApiUrl(req.url);
|
||||||
|
|
||||||
|
if (!token || req.headers.has('Authorization')) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -14,6 +14,8 @@ chat/
|
|||||||
├── domain/
|
├── domain/
|
||||||
│ └── rules/
|
│ └── rules/
|
||||||
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
||||||
|
│ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates
|
||||||
|
│ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision
|
||||||
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
|
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
|
||||||
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
|
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
|
||||||
│
|
│
|
||||||
@@ -95,6 +97,10 @@ sequenceDiagram
|
|||||||
User->>DC: broadcastMessage(delete-message)
|
User->>DC: broadcastMessage(delete-message)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Message integrity
|
||||||
|
|
||||||
|
Outgoing creates/edits/deletes also emit signed `message-revision` events and persist revision audit rows locally. Sync inventories include `revision` and `headHash`; merge prefers a verified higher revision over legacy timestamp comparison. See `agents-docs/features/message-integrity.md` and `MessageRevisionService`.
|
||||||
|
|
||||||
## Text channel scoping
|
## Text channel scoping
|
||||||
|
|
||||||
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
|
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
type Message,
|
||||||
|
type MessageRevision,
|
||||||
|
type MessageRevisionType
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
|
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
|
||||||
|
import { buildMessageRevision } from '../../domain/rules/message-revision.builder.rules';
|
||||||
|
import { getMessageRevision } from '../../domain/rules/message-integrity.rules';
|
||||||
|
import { attachRevisionSignatureIfPossible, shouldAcceptRevisionWithoutRegisteredKey } from '../../domain/rules/message-revision-signing.rules';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MessageRevisionService {
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly signing = inject(MessageSigningService);
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
async createSignedRevision(input: {
|
||||||
|
message: Message;
|
||||||
|
type: MessageRevisionType;
|
||||||
|
actorId: string;
|
||||||
|
content?: string;
|
||||||
|
editedAt: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
pluginId?: string;
|
||||||
|
sign?: boolean;
|
||||||
|
}): Promise<MessageRevision> {
|
||||||
|
const prevRevisionHash = input.type === 'create'
|
||||||
|
? ''
|
||||||
|
: (input.message.headHash ?? '');
|
||||||
|
|
||||||
|
let revision = await buildMessageRevision({
|
||||||
|
message: input.message,
|
||||||
|
type: input.type,
|
||||||
|
actorId: input.actorId,
|
||||||
|
content: input.content,
|
||||||
|
editedAt: input.editedAt,
|
||||||
|
isDeleted: input.isDeleted,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
prevRevisionHash
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.sign !== false) {
|
||||||
|
revision = await attachRevisionSignatureIfPossible(
|
||||||
|
revision,
|
||||||
|
(value) => this.signing.signRevision(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistRevision(revision: MessageRevision): Promise<void> {
|
||||||
|
await this.db.saveMessageRevision(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastRevision(revision: MessageRevision): void {
|
||||||
|
this.realtime.broadcastMessage({
|
||||||
|
type: 'message-revision',
|
||||||
|
revision
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistAndBroadcast(revision: MessageRevision): Promise<void> {
|
||||||
|
await this.persistRevision(revision);
|
||||||
|
this.broadcastRevision(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildCreateRevision(message: Message, actorId: string): Promise<MessageRevision> {
|
||||||
|
return await this.createSignedRevision({
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
revision: 0
|
||||||
|
},
|
||||||
|
type: 'create',
|
||||||
|
actorId,
|
||||||
|
editedAt: message.timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveNextRevision(message: Message): number {
|
||||||
|
return getMessageRevision(message) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyRevision(revision: MessageRevision): Promise<boolean> {
|
||||||
|
if (!revision.signature) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyJwk = await this.signing.fetchSigningPublicKey(revision.actorId);
|
||||||
|
|
||||||
|
if (shouldAcceptRevisionWithoutRegisteredKey(revision, publicKeyJwk)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKeyJwk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.signing.verifyRevisionSignature(revision, publicKeyJwk);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import type { Message } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
buildMessageHeadState,
|
||||||
|
computeMessageHeadHash,
|
||||||
|
getMessageRevision,
|
||||||
|
inventoryNeedsRefresh,
|
||||||
|
resolveMessageRevision
|
||||||
|
} from './message-integrity.rules';
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
|
return {
|
||||||
|
id: 'message-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
senderName: 'User 1',
|
||||||
|
content: 'hello',
|
||||||
|
timestamp: 1_000,
|
||||||
|
reactions: [],
|
||||||
|
isDeleted: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('message-integrity.rules', () => {
|
||||||
|
it('defaults missing revision to zero', () => {
|
||||||
|
expect(getMessageRevision(createMessage())).toBe(0);
|
||||||
|
expect(getMessageRevision(createMessage({ revision: 3 }))).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces stable head hashes for the same canonical state', async () => {
|
||||||
|
const message = createMessage({ revision: 0 });
|
||||||
|
const state = buildMessageHeadState(message, 0);
|
||||||
|
const first = await computeMessageHeadHash(state);
|
||||||
|
const second = await computeMessageHeadHash(state);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(first).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes head hash when content or revision changes', async () => {
|
||||||
|
const original = await computeMessageHeadHash(buildMessageHeadState(createMessage({ revision: 0 }), 0));
|
||||||
|
const edited = await computeMessageHeadHash(buildMessageHeadState(createMessage({
|
||||||
|
content: 'edited',
|
||||||
|
editedAt: 2_000,
|
||||||
|
revision: 1
|
||||||
|
}), 1));
|
||||||
|
|
||||||
|
expect(edited).not.toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests refresh when revision is newer', () => {
|
||||||
|
expect(inventoryNeedsRefresh(
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' },
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'aaa' }
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests refresh when revision matches but head hash differs', () => {
|
||||||
|
expect(inventoryNeedsRefresh(
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'tampered' },
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'canonical' }
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not request refresh when revision and head hash match', () => {
|
||||||
|
expect(inventoryNeedsRefresh(
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' },
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' }
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy timestamp comparison when remote inventory lacks integrity fields', () => {
|
||||||
|
expect(inventoryNeedsRefresh(
|
||||||
|
{ ts: 20, rc: 0, ac: 0 },
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
|
||||||
|
)).toBe(true);
|
||||||
|
|
||||||
|
expect(inventoryNeedsRefresh(
|
||||||
|
{ ts: 10, rc: 1, ac: 0 },
|
||||||
|
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves next revision for create, edit, and delete flows', () => {
|
||||||
|
expect(resolveMessageRevision(undefined, 'create')).toBe(0);
|
||||||
|
expect(resolveMessageRevision(createMessage({ revision: 0 }), 'author-edit')).toBe(1);
|
||||||
|
expect(resolveMessageRevision(createMessage({ revision: 4 }), 'moderate-delete')).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Message } from '../../../../shared-kernel';
|
||||||
|
import type { MessageRevisionType } from '../../../../shared-kernel/message-revision.models';
|
||||||
|
import { getMessageTimestamp } from './message.rules';
|
||||||
|
|
||||||
|
export interface MessageHeadState {
|
||||||
|
messageId: string;
|
||||||
|
revision: number;
|
||||||
|
senderId: string;
|
||||||
|
content: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
editedAt: number;
|
||||||
|
channelId: string;
|
||||||
|
replyToId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryIntegritySnapshot {
|
||||||
|
ts: number;
|
||||||
|
rc: number;
|
||||||
|
ac: number;
|
||||||
|
revision: number;
|
||||||
|
headHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteInventoryItem = {
|
||||||
|
id: string;
|
||||||
|
ts: number;
|
||||||
|
rc?: number;
|
||||||
|
ac?: number;
|
||||||
|
revision?: number;
|
||||||
|
headHash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageRevisionAction = MessageRevisionType;
|
||||||
|
|
||||||
|
export function getMessageRevision(message: Pick<Message, 'revision'> | null | undefined): number {
|
||||||
|
return typeof message?.revision === 'number' && message.revision >= 0
|
||||||
|
? message.revision
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMessageRevision(
|
||||||
|
existing: Pick<Message, 'revision'> | null | undefined,
|
||||||
|
_action: MessageRevisionAction
|
||||||
|
): number {
|
||||||
|
return getMessageRevision(existing ?? undefined) + (existing ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMessageHeadState(message: Message, revision = getMessageRevision(message)): MessageHeadState {
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
revision,
|
||||||
|
senderId: message.senderId,
|
||||||
|
content: message.isDeleted ? '' : message.content,
|
||||||
|
isDeleted: message.isDeleted,
|
||||||
|
editedAt: getMessageTimestamp(message),
|
||||||
|
channelId: message.channelId ?? 'general',
|
||||||
|
replyToId: message.replyToId ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeMessageHeadHash(state: MessageHeadState): Promise<string> {
|
||||||
|
const canonical = JSON.stringify(state, Object.keys(state).sort());
|
||||||
|
|
||||||
|
if (typeof globalThis.crypto?.subtle?.digest !== 'function') {
|
||||||
|
throw new Error('Web Crypto digest is unavailable for message integrity hashing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = await globalThis.crypto.subtle.digest(
|
||||||
|
'SHA-256',
|
||||||
|
new TextEncoder().encode(canonical)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeMessageHeadHashFromMessage(
|
||||||
|
message: Message,
|
||||||
|
revision = getMessageRevision(message)
|
||||||
|
): Promise<string> {
|
||||||
|
return await computeMessageHeadHash(buildMessageHeadState(message, revision));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIntegrityFields(item: RemoteInventoryItem): item is RemoteInventoryItem & {
|
||||||
|
revision: number;
|
||||||
|
headHash: string;
|
||||||
|
} {
|
||||||
|
return typeof item.revision === 'number'
|
||||||
|
&& typeof item.headHash === 'string'
|
||||||
|
&& item.headHash.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryNeedsRefresh(
|
||||||
|
remote: RemoteInventoryItem,
|
||||||
|
local: InventoryIntegritySnapshot
|
||||||
|
): boolean {
|
||||||
|
if (!hasIntegrityFields(remote)) {
|
||||||
|
return remote.ts > local.ts
|
||||||
|
|| (remote.rc !== undefined && remote.rc !== local.rc)
|
||||||
|
|| (remote.ac !== undefined && remote.ac !== local.ac);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote.revision > local.revision) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote.revision < local.revision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote.headHash !== local.headHash) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote.ts > local.ts
|
||||||
|
|| (remote.rc !== undefined && remote.rc !== local.rc)
|
||||||
|
|| (remote.ac !== undefined && remote.ac !== local.ac);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldApplyIncomingRevision(
|
||||||
|
incomingRevision: number,
|
||||||
|
existingRevision: number,
|
||||||
|
incomingHeadHash: string,
|
||||||
|
existingHeadHash: string
|
||||||
|
): boolean {
|
||||||
|
if (incomingRevision > existingRevision) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingRevision < existingRevision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return incomingHeadHash !== existingHeadHash;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import type { MessageRevision } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
attachRevisionSignatureIfPossible,
|
||||||
|
shouldAcceptRevisionWithoutRegisteredKey
|
||||||
|
} from './message-revision-signing.rules';
|
||||||
|
|
||||||
|
describe('message-revision-signing.rules', () => {
|
||||||
|
const revision: MessageRevision = {
|
||||||
|
messageId: 'msg-1',
|
||||||
|
revision: 0,
|
||||||
|
prevRevisionHash: '',
|
||||||
|
headHash: 'hash',
|
||||||
|
type: 'create',
|
||||||
|
actorId: 'user-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
channelId: 'general',
|
||||||
|
senderName: 'User',
|
||||||
|
content: 'hello',
|
||||||
|
editedAt: 1,
|
||||||
|
isDeleted: false
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps revisions unsigned when signing fails', async () => {
|
||||||
|
const signed = await attachRevisionSignatureIfPossible(
|
||||||
|
revision,
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error('Ed25519 unavailable');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(signed.signature).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts signed revisions while the sender key is still registering', () => {
|
||||||
|
expect(shouldAcceptRevisionWithoutRegisteredKey({
|
||||||
|
...revision,
|
||||||
|
signature: 'signature'
|
||||||
|
}, null)).toBe(true);
|
||||||
|
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MessageRevision } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
export async function attachRevisionSignatureIfPossible(
|
||||||
|
revision: MessageRevision,
|
||||||
|
signRevision: (value: MessageRevision) => Promise<string>
|
||||||
|
): Promise<MessageRevision> {
|
||||||
|
try {
|
||||||
|
const signature = await signRevision(revision);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...revision,
|
||||||
|
signature
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAcceptRevisionWithoutRegisteredKey(
|
||||||
|
revision: MessageRevision,
|
||||||
|
publicKeyJwk: JsonWebKey | null
|
||||||
|
): boolean {
|
||||||
|
return !!revision.signature && !publicKeyJwk;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import type { Message } from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
buildMessageRevision,
|
||||||
|
materializeMessageFromRevision,
|
||||||
|
revisionBeatsMessage
|
||||||
|
} from './message-revision.builder.rules';
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
|
return {
|
||||||
|
id: 'message-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
senderName: 'User 1',
|
||||||
|
content: 'hello',
|
||||||
|
timestamp: 1_000,
|
||||||
|
reactions: [],
|
||||||
|
isDeleted: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('message-revision.builder.rules', () => {
|
||||||
|
it('builds create revisions at revision zero', async () => {
|
||||||
|
const message = createMessage();
|
||||||
|
const revision = await buildMessageRevision({
|
||||||
|
message,
|
||||||
|
type: 'create',
|
||||||
|
actorId: 'user-1',
|
||||||
|
editedAt: 1_000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revision.revision).toBe(0);
|
||||||
|
expect(revision.headHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(revision.type).toBe('create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments revision for author edits', async () => {
|
||||||
|
const message = createMessage({ revision: 0, headHash: 'abc' });
|
||||||
|
const revision = await buildMessageRevision({
|
||||||
|
message,
|
||||||
|
type: 'author-edit',
|
||||||
|
actorId: 'user-1',
|
||||||
|
content: 'edited',
|
||||||
|
editedAt: 2_000,
|
||||||
|
prevRevisionHash: 'abc'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revision.revision).toBe(1);
|
||||||
|
expect(revision.prevRevisionHash).toBe('abc');
|
||||||
|
expect(revision.content).toBe('edited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes message state from a revision', async () => {
|
||||||
|
const revision = await buildMessageRevision({
|
||||||
|
message: createMessage(),
|
||||||
|
type: 'author-edit',
|
||||||
|
actorId: 'user-1',
|
||||||
|
content: 'edited',
|
||||||
|
editedAt: 2_000
|
||||||
|
});
|
||||||
|
const materialized = materializeMessageFromRevision(createMessage(), revision);
|
||||||
|
|
||||||
|
expect(materialized.revision).toBe(1);
|
||||||
|
expect(materialized.content).toBe('edited');
|
||||||
|
expect(materialized.headHash).toBe(revision.headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects when a revision should replace local state', async () => {
|
||||||
|
const local = materializeMessageFromRevision(
|
||||||
|
createMessage({ revision: 0, headHash: 'old' }),
|
||||||
|
await buildMessageRevision({
|
||||||
|
message: createMessage(),
|
||||||
|
type: 'create',
|
||||||
|
actorId: 'user-1',
|
||||||
|
editedAt: 1_000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const incoming = await buildMessageRevision({
|
||||||
|
message: createMessage({ revision: 0, headHash: local.headHash }),
|
||||||
|
type: 'author-edit',
|
||||||
|
actorId: 'user-1',
|
||||||
|
content: 'edited',
|
||||||
|
editedAt: 2_000,
|
||||||
|
prevRevisionHash: local.headHash ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revisionBeatsMessage(incoming, local)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
DELETED_MESSAGE_CONTENT,
|
||||||
|
type Message,
|
||||||
|
type MessageRevision,
|
||||||
|
type MessageRevisionType
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
|
import {
|
||||||
|
buildMessageHeadState,
|
||||||
|
computeMessageHeadHash,
|
||||||
|
getMessageRevision,
|
||||||
|
resolveMessageRevision
|
||||||
|
} from './message-integrity.rules';
|
||||||
|
import { getMessageTimestamp } from './message.rules';
|
||||||
|
|
||||||
|
export interface BuildMessageRevisionInput {
|
||||||
|
message: Message;
|
||||||
|
type: MessageRevisionType;
|
||||||
|
actorId: string;
|
||||||
|
content?: string;
|
||||||
|
editedAt: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
pluginId?: string;
|
||||||
|
prevRevisionHash?: string;
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMessageRevision(input: BuildMessageRevisionInput): Promise<MessageRevision> {
|
||||||
|
const revision = input.type === 'create'
|
||||||
|
? 0
|
||||||
|
: resolveMessageRevision(input.message, input.type);
|
||||||
|
const isDeleted = input.isDeleted ?? input.type.includes('delete');
|
||||||
|
const content = isDeleted
|
||||||
|
? DELETED_MESSAGE_CONTENT
|
||||||
|
: (input.content ?? input.message.content);
|
||||||
|
const materializedMessage: Message = {
|
||||||
|
...input.message,
|
||||||
|
content,
|
||||||
|
editedAt: input.editedAt,
|
||||||
|
isDeleted,
|
||||||
|
revision
|
||||||
|
};
|
||||||
|
const headHash = await computeMessageHeadHash(buildMessageHeadState(materializedMessage, revision));
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: input.message.id,
|
||||||
|
revision,
|
||||||
|
prevRevisionHash: input.prevRevisionHash ?? '',
|
||||||
|
headHash,
|
||||||
|
type: input.type,
|
||||||
|
actorId: input.actorId,
|
||||||
|
senderId: input.message.senderId,
|
||||||
|
roomId: input.message.roomId,
|
||||||
|
channelId: input.message.channelId,
|
||||||
|
senderName: input.message.senderName,
|
||||||
|
content,
|
||||||
|
editedAt: input.editedAt,
|
||||||
|
isDeleted,
|
||||||
|
replyToId: input.message.replyToId,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
signature: input.signature
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeMessageFromRevision(
|
||||||
|
existing: Message | null,
|
||||||
|
revision: MessageRevision
|
||||||
|
): Message {
|
||||||
|
const base = existing ?? {
|
||||||
|
id: revision.messageId,
|
||||||
|
roomId: revision.roomId,
|
||||||
|
channelId: revision.channelId,
|
||||||
|
senderId: revision.senderId,
|
||||||
|
senderName: revision.senderName ?? 'Unknown',
|
||||||
|
content: revision.content ?? '',
|
||||||
|
timestamp: revision.editedAt,
|
||||||
|
reactions: [],
|
||||||
|
isDeleted: revision.isDeleted,
|
||||||
|
replyToId: revision.replyToId
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
roomId: revision.roomId,
|
||||||
|
channelId: revision.channelId ?? base.channelId,
|
||||||
|
senderId: revision.senderId,
|
||||||
|
senderName: revision.senderName ?? base.senderName,
|
||||||
|
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
|
||||||
|
editedAt: revision.editedAt,
|
||||||
|
revision: revision.revision,
|
||||||
|
headHash: revision.headHash,
|
||||||
|
isDeleted: revision.isDeleted,
|
||||||
|
replyToId: revision.replyToId ?? base.replyToId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisionTimestamp(revision: MessageRevision): number {
|
||||||
|
return revision.editedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisionBeatsMessage(revision: MessageRevision, message: Message | null): boolean {
|
||||||
|
const existingRevision = getMessageRevision(message ?? undefined);
|
||||||
|
|
||||||
|
if (revision.revision !== existingRevision) {
|
||||||
|
return revision.revision > existingRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return revision.headHash !== message?.headHash;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import { findMissingIds } from './message-sync.rules';
|
||||||
|
|
||||||
|
describe('message-sync.rules', () => {
|
||||||
|
it('requests ids with newer revision or mismatched head hash', () => {
|
||||||
|
const localMap = new Map([
|
||||||
|
['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }],
|
||||||
|
['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missing = findMissingIds([
|
||||||
|
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
|
||||||
|
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
|
||||||
|
{ id: 'm3', ts: 1, rc: 0, ac: 0, revision: 0, headHash: 'ddd' }
|
||||||
|
], localMap);
|
||||||
|
|
||||||
|
expect(missing).toEqual(['m1', 'm3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
inventoryNeedsRefresh,
|
||||||
|
type InventoryIntegritySnapshot,
|
||||||
|
type RemoteInventoryItem
|
||||||
|
} from './message-integrity.rules';
|
||||||
|
|
||||||
/** Maximum number of messages to include in sync inventories.
|
/** Maximum number of messages to include in sync inventories.
|
||||||
*
|
*
|
||||||
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
|
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
|
||||||
@@ -27,7 +33,9 @@ export interface InventoryItem {
|
|||||||
id: string;
|
id: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
rc: number;
|
rc: number;
|
||||||
ac?: number;
|
ac: number;
|
||||||
|
revision: number;
|
||||||
|
headHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Splits an array into chunks of the given size. */
|
/** Splits an array into chunks of the given size. */
|
||||||
@@ -43,15 +51,15 @@ export function chunkArray<T>(items: T[], size: number): T[][] {
|
|||||||
|
|
||||||
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||||
export function findMissingIds(
|
export function findMissingIds(
|
||||||
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
|
remoteItems: readonly RemoteInventoryItem[],
|
||||||
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
|
localMap: ReadonlyMap<string, InventoryIntegritySnapshot | InventoryItem>
|
||||||
): string[] {
|
): string[] {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
for (const item of remoteItems) {
|
for (const item of remoteItems) {
|
||||||
const local = localMap.get(item.id);
|
const local = localMap.get(item.id);
|
||||||
|
|
||||||
if (!local || item.ts > local.ts || (item.rc !== undefined && item.rc !== local.rc) || (item.ac !== undefined && item.ac !== local.ac)) {
|
if (!local || inventoryNeedsRefresh(item, local)) {
|
||||||
missing.push(item.id);
|
missing.push(item.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import { remarkStripDangerousContent } from './remark-sanitize.rules';
|
||||||
|
|
||||||
|
async function parseMarkdown(content: string) {
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkStripDangerousContent());
|
||||||
|
|
||||||
|
return processor.run(processor.parse(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remarkStripDangerousContent', () => {
|
||||||
|
it('removes raw html nodes from markdown', async () => {
|
||||||
|
const tree = await parseMarkdown('Hello <script>alert(1)</script> world');
|
||||||
|
|
||||||
|
expect(JSON.stringify(tree)).not.toContain('script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears dangerous link protocols', async () => {
|
||||||
|
const tree = await parseMarkdown('[click](javascript:alert(1))');
|
||||||
|
|
||||||
|
expect(JSON.stringify(tree)).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves safe custom emoji image data urls', async () => {
|
||||||
|
const tree = await parseMarkdown('');
|
||||||
|
|
||||||
|
expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears dangerous non-image data urls', async () => {
|
||||||
|
const tree = await parseMarkdown('</script>)');
|
||||||
|
|
||||||
|
expect(JSON.stringify(tree)).not.toContain('data:text/html');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Parent, Root } from 'mdast';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
const ALLOWED_DATA_IMAGE_MIME_PATTERN = /^data:image\/(?:webp|gif|jpe?g|png)(?:;|$)/i;
|
||||||
|
|
||||||
|
function isBlockedMarkdownUrl(url: string): boolean {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
if (/^(?:javascript|vbscript):/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^data:/i.test(trimmed)) {
|
||||||
|
return !ALLOWED_DATA_IMAGE_MIME_PATTERN.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remarkStripDangerousContent(): () => (tree: Root) => void {
|
||||||
|
return () => (tree: Root) => {
|
||||||
|
const htmlNodes: { parent: Parent; index: number }[] = [];
|
||||||
|
|
||||||
|
visit(tree, 'html', (_node, index, parent) => {
|
||||||
|
if (parent && typeof index === 'number') {
|
||||||
|
htmlNodes.push({ parent, index });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of htmlNodes.sort((left, right) => right.index - left.index)) {
|
||||||
|
entry.parent.children.splice(entry.index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(tree, 'link', (node) => {
|
||||||
|
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
|
||||||
|
node.url = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
visit(tree, 'image', (node) => {
|
||||||
|
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
|
||||||
|
node.url = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import remarkBreaks from 'remark-breaks';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
|
import { remarkStripDangerousContent } from '../../../../../domain/rules/remark-sanitize.rules';
|
||||||
import {
|
import {
|
||||||
isSoundcloudUrl,
|
isSoundcloudUrl,
|
||||||
isSpotifyUrl,
|
isSpotifyUrl,
|
||||||
@@ -53,7 +54,8 @@ const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
|
|||||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||||
const REMARK_PROCESSOR = unified().use(remarkParse)
|
const REMARK_PROCESSOR = unified().use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
.use(remarkBreaks);
|
.use(remarkBreaks)
|
||||||
|
.use(remarkStripDangerousContent());
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-message-markdown',
|
selector: 'app-chat-message-markdown',
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ Plugins can inspect the current interaction context through `api.context.getCurr
|
|||||||
|
|
||||||
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
|
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
Plugins run in the product-client renderer with the same origin and capability surface as the host app. Process isolation (separate `BrowserView` or worker sandboxes) is intentionally out of scope because the runtime loads entrypoints through dynamic `import()`.
|
||||||
|
|
||||||
|
Remote plugin fetches are constrained as follows:
|
||||||
|
|
||||||
|
- Store and host installs require **HTTPS** entrypoints and bundle URLs; `file://` fetches from the renderer are blocked.
|
||||||
|
- When a source manifest entry or cached bundle declares `bundle.integrity` (SHA-256), `PluginHostService` verifies the digest before `import()`.
|
||||||
|
- Desktop local plugins and cached bundles continue to load from Electron-controlled paths under app data (`plugins/`, `plugin-bundles/`).
|
||||||
|
- Capability grants remain user-consented; integrity checks do not replace the existing capability model.
|
||||||
|
|
||||||
|
Treat third-party plugin code as trusted only after the user installs it and grants the declared capabilities.
|
||||||
|
|
||||||
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
|
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
|
||||||
|
|
||||||
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
|
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
type PluginClientApiMethodPath
|
type PluginClientApiMethodPath
|
||||||
} from '../../domain/logic/plugin-client-api-surface.rules';
|
} from '../../domain/logic/plugin-client-api-surface.rules';
|
||||||
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
|
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
|
||||||
|
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
|
||||||
|
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
|
||||||
import { PluginClientApiService } from './plugin-client-api.service';
|
import { PluginClientApiService } from './plugin-client-api.service';
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
import { PluginLoggerService } from './plugin-logger.service';
|
||||||
@@ -106,17 +108,31 @@ describe('PluginClientApiService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends plugin messages and broadcasts them to peers', () => {
|
it('sends plugin messages and broadcasts them to peers', async () => {
|
||||||
const api = context.service.createApi(TEST_MANIFEST);
|
const api = context.service.createApi(TEST_MANIFEST);
|
||||||
const message = api.messages.send('hello plugin');
|
const message = api.messages.send('hello plugin');
|
||||||
|
|
||||||
expect(message.content).toBe('hello plugin');
|
expect(message.content).toBe('hello plugin');
|
||||||
expect(message.roomId).toBe('room-1');
|
expect(message.roomId).toBe('room-1');
|
||||||
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(context.store.dispatch).toHaveBeenCalledWith(
|
||||||
|
MessagesActions.sendMessageSuccess({
|
||||||
|
message: expect.objectContaining({
|
||||||
|
content: 'hello plugin',
|
||||||
|
roomId: 'room-1'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
type: 'chat-message',
|
type: 'chat-message',
|
||||||
message
|
message: expect.objectContaining({
|
||||||
|
content: 'hello plugin',
|
||||||
|
roomId: 'room-1'
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes typing state through the realtime facade', () => {
|
it('publishes typing state through the realtime facade', () => {
|
||||||
@@ -264,6 +280,11 @@ interface ServiceTestContext {
|
|||||||
setLocalStream: ReturnType<typeof vi.fn>;
|
setLocalStream: ReturnType<typeof vi.fn>;
|
||||||
setOutputVolume: ReturnType<typeof vi.fn>;
|
setOutputVolume: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
messageRevisions: {
|
||||||
|
broadcastRevision: ReturnType<typeof vi.fn>;
|
||||||
|
createSignedRevision: ReturnType<typeof vi.fn>;
|
||||||
|
persistRevision: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServiceTestContext(): ServiceTestContext {
|
function createServiceTestContext(): ServiceTestContext {
|
||||||
@@ -305,6 +326,28 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
setLocalStream: vi.fn(async () => undefined),
|
setLocalStream: vi.fn(async () => undefined),
|
||||||
setOutputVolume: vi.fn()
|
setOutputVolume: vi.fn()
|
||||||
};
|
};
|
||||||
|
const messageRevisions = {
|
||||||
|
createSignedRevision: vi.fn(async (input: Parameters<MessageRevisionService['createSignedRevision']>[0]) => ({
|
||||||
|
messageId: input.message.id,
|
||||||
|
revision: input.type === 'create' ? 0 : (input.message.revision ?? 0) + 1,
|
||||||
|
prevRevisionHash: input.type === 'create' ? '' : (input.message.headHash ?? ''),
|
||||||
|
headHash: 'test-head-hash',
|
||||||
|
type: input.type,
|
||||||
|
actorId: input.actorId,
|
||||||
|
senderId: input.message.senderId,
|
||||||
|
roomId: input.message.roomId,
|
||||||
|
channelId: input.message.channelId,
|
||||||
|
senderName: input.message.senderName,
|
||||||
|
content: input.content ?? input.message.content,
|
||||||
|
editedAt: input.editedAt,
|
||||||
|
isDeleted: input.isDeleted ?? false,
|
||||||
|
replyToId: input.message.replyToId,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
signature: input.sign === false ? undefined : 'test-signature'
|
||||||
|
})),
|
||||||
|
persistRevision: vi.fn(async () => undefined),
|
||||||
|
broadcastRevision: vi.fn()
|
||||||
|
};
|
||||||
const realtime = {
|
const realtime = {
|
||||||
onSignalingMessage: new Subject<unknown>(),
|
onSignalingMessage: new Subject<unknown>(),
|
||||||
sendRawMessage: vi.fn()
|
sendRawMessage: vi.fn()
|
||||||
@@ -357,11 +400,24 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
{
|
{
|
||||||
provide: DatabaseService,
|
provide: DatabaseService,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
getMessageById: vi.fn(async () => null),
|
||||||
saveMessage: vi.fn(async () => undefined),
|
saveMessage: vi.fn(async () => undefined),
|
||||||
updateMessage: vi.fn(async () => undefined),
|
updateMessage: vi.fn(async () => undefined),
|
||||||
updateRoom: vi.fn(async () => undefined)
|
updateRoom: vi.fn(async () => undefined)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: MessageRevisionService,
|
||||||
|
useValue: messageRevisions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MessageSigningService,
|
||||||
|
useValue: {
|
||||||
|
signRevision: vi.fn(async () => 'test-signature'),
|
||||||
|
fetchSigningPublicKey: vi.fn(async () => null),
|
||||||
|
verifyRevisionSignature: vi.fn(async () => true)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PluginDesktopStateService,
|
provide: PluginDesktopStateService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -426,7 +482,8 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
storage,
|
storage,
|
||||||
store,
|
store,
|
||||||
uiRegistry,
|
uiRegistry,
|
||||||
voice
|
voice,
|
||||||
|
messageRevisions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Action, Store } from '@ngrx/store';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
Channel,
|
Channel,
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Message,
|
Message,
|
||||||
|
MessageRevision,
|
||||||
PluginCapabilityId,
|
PluginCapabilityId,
|
||||||
PluginEventEnvelope,
|
PluginEventEnvelope,
|
||||||
TojuPluginManifest,
|
TojuPluginManifest,
|
||||||
@@ -49,6 +50,8 @@ import { PluginLoggerService } from './plugin-logger.service';
|
|||||||
import { PluginMessageBusService } from './plugin-message-bus.service';
|
import { PluginMessageBusService } from './plugin-message-bus.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||||
|
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
|
||||||
|
import { materializeMessageFromRevision } from '../../../chat/domain/rules/message-revision.builder.rules';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginClientApiService {
|
export class PluginClientApiService {
|
||||||
@@ -63,6 +66,7 @@ export class PluginClientApiService {
|
|||||||
private readonly storage = inject(PluginStorageService);
|
private readonly storage = inject(PluginStorageService);
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||||
private readonly voice = inject(VoiceConnectionFacade);
|
private readonly voice = inject(VoiceConnectionFacade);
|
||||||
|
private readonly messageRevisions = inject(MessageRevisionService);
|
||||||
|
|
||||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -380,7 +384,6 @@ export class PluginClientApiService {
|
|||||||
|
|
||||||
this.serverDirectory.updateServer(room.id, {
|
this.serverDirectory.updateServer(room.id, {
|
||||||
actingRole: isOwner ? 'host' : undefined,
|
actingRole: isOwner ? 'host' : undefined,
|
||||||
currentOwnerId: currentUser.id,
|
|
||||||
icon,
|
icon,
|
||||||
iconUpdatedAt
|
iconUpdatedAt
|
||||||
}, {
|
}, {
|
||||||
@@ -601,7 +604,8 @@ export class PluginClientApiService {
|
|||||||
|
|
||||||
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
|
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
|
||||||
const roomId = this.requireRoomId();
|
const roomId = this.requireRoomId();
|
||||||
const message: Message = {
|
const timestamp = Date.now();
|
||||||
|
const draftMessage: Message = {
|
||||||
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
|
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
|
||||||
content: request.content,
|
content: request.content,
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -610,53 +614,74 @@ export class PluginClientApiService {
|
|||||||
roomId,
|
roomId,
|
||||||
senderId: request.pluginUserId,
|
senderId: request.pluginUserId,
|
||||||
senderName: request.pluginUserId,
|
senderName: request.pluginUserId,
|
||||||
timestamp: Date.now()
|
timestamp,
|
||||||
|
revision: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
void this.emitPluginMessageRevision(pluginId, {
|
||||||
this.persistPluginMessage(pluginId, message);
|
draftMessage,
|
||||||
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
type: 'create',
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
actorId: request.pluginUserId,
|
||||||
|
editedAt: timestamp,
|
||||||
|
pluginId,
|
||||||
|
sign: false,
|
||||||
|
dispatch: (message) => MessagesActions.receiveMessage({ message })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private deletePluginMessage(pluginId: string, messageId: string): void {
|
private deletePluginMessage(pluginId: string, messageId: string): void {
|
||||||
this.persistPluginMessageUpdate(pluginId, messageId, {
|
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||||
content: '[Message deleted]',
|
type: 'plugin-delete',
|
||||||
editedAt: Date.now(),
|
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||||
isDeleted: true
|
message: existing,
|
||||||
|
type: 'plugin-delete',
|
||||||
|
actorId: this.currentUser()?.id ?? pluginId,
|
||||||
|
editedAt,
|
||||||
|
isDeleted: true,
|
||||||
|
pluginId,
|
||||||
|
sign: false
|
||||||
|
}),
|
||||||
|
legacyBroadcast: (editedAt) => ({
|
||||||
|
deletedAt: editedAt,
|
||||||
|
messageId,
|
||||||
|
type: 'message-deleted'
|
||||||
|
}),
|
||||||
|
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
|
||||||
});
|
});
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
|
||||||
this.voice.broadcastMessage({
|
|
||||||
deletedAt: Date.now(),
|
|
||||||
messageId,
|
|
||||||
type: 'message-deleted'
|
|
||||||
} as unknown as ChatEvent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
||||||
const editedAt = Date.now();
|
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||||
|
type: 'plugin-edit',
|
||||||
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
|
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.editMessageSuccess({
|
|
||||||
content,
|
content,
|
||||||
editedAt,
|
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||||
messageId
|
message: existing,
|
||||||
}));
|
type: 'plugin-edit',
|
||||||
|
actorId: this.currentUser()?.id ?? pluginId,
|
||||||
this.voice.broadcastMessage({
|
content,
|
||||||
content,
|
editedAt,
|
||||||
editedAt,
|
pluginId,
|
||||||
messageId,
|
sign: false
|
||||||
type: 'message-edited'
|
}),
|
||||||
} as unknown as ChatEvent);
|
legacyBroadcast: (editedAt) => ({
|
||||||
|
content,
|
||||||
|
editedAt,
|
||||||
|
messageId,
|
||||||
|
type: 'message-edited'
|
||||||
|
}),
|
||||||
|
dispatch: (message) => MessagesActions.editMessageSuccess({
|
||||||
|
content: message.content,
|
||||||
|
editedAt: message.editedAt ?? Date.now(),
|
||||||
|
messageId
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
const roomId = this.requireRoomId();
|
const roomId = this.requireRoomId();
|
||||||
const message: Message = {
|
const timestamp = Date.now();
|
||||||
|
const draftMessage: Message = {
|
||||||
channelId: channelId ?? this.activeChannelId() ?? 'general',
|
channelId: channelId ?? this.activeChannelId() ?? 'general',
|
||||||
content,
|
content,
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -665,14 +690,89 @@ export class PluginClientApiService {
|
|||||||
roomId,
|
roomId,
|
||||||
senderId: currentUser?.id ?? 'plugin',
|
senderId: currentUser?.id ?? 'plugin',
|
||||||
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
|
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
|
||||||
timestamp: Date.now()
|
timestamp,
|
||||||
|
revision: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
this.persistPluginMessage(pluginId, message);
|
void this.emitPluginMessageRevision(pluginId, {
|
||||||
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
draftMessage,
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
type: 'create',
|
||||||
|
actorId: currentUser?.id ?? pluginId,
|
||||||
|
editedAt: timestamp,
|
||||||
|
pluginId,
|
||||||
|
sign: !!currentUser,
|
||||||
|
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
|
||||||
|
});
|
||||||
|
|
||||||
return message;
|
return draftMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitPluginMessageRevision(
|
||||||
|
pluginId: string,
|
||||||
|
input: {
|
||||||
|
draftMessage: Message;
|
||||||
|
type: 'create';
|
||||||
|
actorId: string;
|
||||||
|
editedAt: number;
|
||||||
|
pluginId: string;
|
||||||
|
sign: boolean;
|
||||||
|
dispatch: (message: Message) => Action;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const revision = await this.messageRevisions.createSignedRevision({
|
||||||
|
message: input.draftMessage,
|
||||||
|
type: input.type,
|
||||||
|
actorId: input.actorId,
|
||||||
|
editedAt: input.editedAt,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
sign: input.sign
|
||||||
|
});
|
||||||
|
const message = materializeMessageFromRevision(null, revision);
|
||||||
|
|
||||||
|
this.logger.info(pluginId, 'Plugin message emitted', { messageId: message.id });
|
||||||
|
await this.db.saveMessage(message);
|
||||||
|
await this.messageRevisions.persistRevision(revision);
|
||||||
|
this.store.dispatch(input.dispatch(message));
|
||||||
|
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||||
|
this.messageRevisions.broadcastRevision(revision);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.warn(pluginId, 'Failed to emit plugin message revision', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitPluginMessageMutation(
|
||||||
|
pluginId: string,
|
||||||
|
messageId: string,
|
||||||
|
input: {
|
||||||
|
type: 'plugin-edit' | 'plugin-delete';
|
||||||
|
content?: string;
|
||||||
|
apply: (existing: Message, editedAt: number) => Promise<MessageRevision>;
|
||||||
|
legacyBroadcast: (editedAt: number) => ChatEvent;
|
||||||
|
dispatch: (message: Message) => Action;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = await this.db.getMessageById(messageId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
this.logger.warn(pluginId, 'Plugin message mutation target not found', { messageId });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editedAt = Date.now();
|
||||||
|
const revision = await input.apply(existing, editedAt);
|
||||||
|
const message = materializeMessageFromRevision(existing, revision);
|
||||||
|
|
||||||
|
await this.db.saveMessage(message);
|
||||||
|
await this.messageRevisions.persistRevision(revision);
|
||||||
|
this.store.dispatch(input.dispatch(message));
|
||||||
|
this.voice.broadcastMessage(input.legacyBroadcast(editedAt) as unknown as ChatEvent);
|
||||||
|
this.messageRevisions.broadcastRevision(revision);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.warn(pluginId, 'Failed to emit plugin message mutation revision', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
|
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
|
||||||
@@ -831,8 +931,7 @@ export class PluginClientApiService {
|
|||||||
|
|
||||||
this.serverDirectory.updateServer(room.id, {
|
this.serverDirectory.updateServer(room.id, {
|
||||||
actingRole: isOwner ? 'host' : undefined,
|
actingRole: isOwner ? 'host' : undefined,
|
||||||
channels,
|
channels
|
||||||
currentOwnerId: currentUser.id
|
|
||||||
}, {
|
}, {
|
||||||
sourceId: room.sourceId,
|
sourceId: room.sourceId,
|
||||||
sourceUrl: room.sourceUrl
|
sourceUrl: room.sourceUrl
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { PluginClientApiService } from './plugin-client-api.service';
|
|||||||
import { PluginLoggerService } from './plugin-logger.service';
|
import { PluginLoggerService } from './plugin-logger.service';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||||
|
import {
|
||||||
|
fileUrlToPath,
|
||||||
|
grantPluginReadRoots
|
||||||
|
} from '../../domain/rules/plugin-local-file.rules';
|
||||||
|
|
||||||
interface ActivePluginRuntime {
|
interface ActivePluginRuntime {
|
||||||
context: TojuPluginActivationContext;
|
context: TojuPluginActivationContext;
|
||||||
@@ -369,43 +373,57 @@ export class PluginHostService {
|
|||||||
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
|
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
|
||||||
|
|
||||||
if (entrypointUrl.startsWith('file://')) {
|
if (entrypointUrl.startsWith('file://')) {
|
||||||
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
|
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl, sourcePath);
|
||||||
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
||||||
|
|
||||||
return { module, moduleObjectUrl };
|
return { module, moduleObjectUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entrypointUrl.startsWith('file://') && !entrypointUrl.startsWith('https://')) {
|
||||||
|
throw new Error('Remote plugin entrypoints must use HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) {
|
if (!entrypointUrl.startsWith('https://')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl);
|
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl, manifest);
|
||||||
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
||||||
|
|
||||||
return { module, moduleObjectUrl };
|
return { module, moduleObjectUrl };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
private async createLocalModuleObjectUrl(entrypointUrl: string, sourcePath?: string): Promise<string> {
|
||||||
const api = this.electronBridge?.getApi();
|
const api = this.electronBridge?.getApi();
|
||||||
|
|
||||||
if (!api) {
|
if (!api) {
|
||||||
throw new Error('Local plugin entrypoints require the desktop app');
|
throw new Error('Local plugin entrypoints require the desktop app');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await grantPluginReadRoots(api, sourcePath, entrypointUrl);
|
||||||
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
|
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
|
||||||
|
|
||||||
|
if (!base64Data) {
|
||||||
|
throw new Error('Plugin entrypoint is not readable from app data');
|
||||||
|
}
|
||||||
|
|
||||||
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
||||||
const source = new TextDecoder().decode(bytes);
|
const source = new TextDecoder().decode(bytes);
|
||||||
|
|
||||||
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
|
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> {
|
||||||
|
if (!entrypointUrl.startsWith('https://')) {
|
||||||
|
throw new Error('Remote plugin entrypoints must use HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
|
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -413,10 +431,26 @@ export class PluginHostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = await response.text();
|
const source = await response.text();
|
||||||
|
const expectedIntegrity = manifest.bundle?.integrity?.trim();
|
||||||
|
|
||||||
|
if (expectedIntegrity) {
|
||||||
|
const actualDigest = await this.sha256Hex(source);
|
||||||
|
|
||||||
|
if (actualDigest !== expectedIntegrity.replace(/^sha256-/i, '').toLowerCase()) {
|
||||||
|
throw new Error('Plugin entrypoint integrity check failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
|
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sha256Hex(source: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
|
||||||
|
const bytes = new Uint8Array(digest);
|
||||||
|
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
private revokeModuleObjectUrl(pluginId: string): void {
|
private revokeModuleObjectUrl(pluginId: string): void {
|
||||||
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
||||||
|
|
||||||
@@ -478,17 +512,6 @@ export class PluginHostService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileUrlToPath(fileUrl: string): string {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const decodedPath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
|
||||||
return decodedPath.slice(1).replace(/\//g, '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
|
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
|
||||||
try {
|
try {
|
||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
|
|
||||||
export interface UpsertPluginRequirementRequest {
|
export interface UpsertPluginRequirementRequest {
|
||||||
actorUserId: string;
|
|
||||||
installUrl?: string;
|
installUrl?: string;
|
||||||
manifest?: TojuPluginManifest;
|
manifest?: TojuPluginManifest;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -20,7 +19,6 @@ export interface UpsertPluginRequirementRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpsertPluginEventDefinitionRequest {
|
export interface UpsertPluginEventDefinitionRequest {
|
||||||
actorUserId: string;
|
|
||||||
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
|
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
|
||||||
maxPayloadBytes?: number;
|
maxPayloadBytes?: number;
|
||||||
rateLimitJson?: string;
|
rateLimitJson?: string;
|
||||||
@@ -48,10 +46,9 @@ export class PluginRequirementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
|
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string): Observable<{ ok: boolean }> {
|
||||||
return this.http.delete<{ ok: boolean }>(
|
return this.http.delete<{ ok: boolean }>(
|
||||||
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
|
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`
|
||||||
{ body: { actorUserId } }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ describe('PluginStoreService', () => {
|
|||||||
|
|
||||||
const service = createService(registerLocalManifest, unregister);
|
const service = createService(registerLocalManifest, unregister);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
|
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
|
||||||
|
|
||||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
|
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
|
||||||
@@ -81,9 +82,11 @@ describe('PluginStoreService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds the official plugin repository for new users', () => {
|
it('seeds the official plugin repository for new users', async () => {
|
||||||
const service = createService(registerLocalManifest, unregister);
|
const service = createService(registerLocalManifest, unregister);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
|
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
OFFICIAL_PLUGIN_SOURCE_URL,
|
OFFICIAL_PLUGIN_SOURCE_URL,
|
||||||
@@ -132,12 +135,15 @@ describe('PluginStoreService', () => {
|
|||||||
],
|
],
|
||||||
title: 'Local Plugins'
|
title: 'Local Plugins'
|
||||||
};
|
};
|
||||||
|
const grantPluginReadRoot = vi.fn(async () => true);
|
||||||
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
|
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
|
||||||
const service = createService(registerLocalManifest, unregister, { readFile });
|
const service = createService(registerLocalManifest, unregister, { grantPluginReadRoot, readFile });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
|
expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
|
||||||
|
expect(grantPluginReadRoot).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin');
|
||||||
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
||||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
|
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
|
||||||
|
|
||||||
@@ -255,7 +261,8 @@ function createService(
|
|||||||
electronApi: {
|
electronApi: {
|
||||||
ensureDir?: (dirPath: string) => Promise<boolean>;
|
ensureDir?: (dirPath: string) => Promise<boolean>;
|
||||||
getAppDataPath?: () => Promise<string>;
|
getAppDataPath?: () => Promise<string>;
|
||||||
readFile?: (filePath: string) => Promise<string>;
|
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||||
|
readFile?: (filePath: string) => Promise<string | null>;
|
||||||
writeFile?: (filePath: string, data: string) => Promise<boolean>;
|
writeFile?: (filePath: string, data: string) => Promise<boolean>;
|
||||||
} | null = null
|
} | null = null
|
||||||
): PluginStoreService {
|
): PluginStoreService {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import type {
|
|||||||
PersistedPluginStoreState,
|
PersistedPluginStoreState,
|
||||||
PluginStoreEntry,
|
PluginStoreEntry,
|
||||||
PluginStoreInstallState,
|
PluginStoreInstallState,
|
||||||
PluginStoreActionLabel,
|
|
||||||
PluginStoreReadme,
|
PluginStoreReadme,
|
||||||
PluginStoreSourceResult
|
PluginStoreSourceResult
|
||||||
} from '../../domain/models/plugin-store.models';
|
} from '../../domain/models/plugin-store.models';
|
||||||
@@ -46,6 +45,10 @@ import { PluginCapabilityService } from './plugin-capability.service';
|
|||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
import { PluginRequirementService } from './plugin-requirement.service';
|
import { PluginRequirementService } from './plugin-requirement.service';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
|
import {
|
||||||
|
fileUrlToPath,
|
||||||
|
grantPluginReadRoots
|
||||||
|
} from '../../domain/rules/plugin-local-file.rules';
|
||||||
|
|
||||||
const STORE_SCHEMA_VERSION = 2;
|
const STORE_SCHEMA_VERSION = 2;
|
||||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
||||||
@@ -138,7 +141,7 @@ export class PluginStoreService {
|
|||||||
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
||||||
|
|
||||||
if (state.sourceUrls.length > 0) {
|
if (state.sourceUrls.length > 0) {
|
||||||
void this.refreshSources();
|
void this.bootstrapSourceRefresh(state.sourceUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
|
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
|
||||||
@@ -169,6 +172,7 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
|
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
|
||||||
|
await this.ensurePluginSourceReadRoot(sourceUrl);
|
||||||
this.saveState();
|
this.saveState();
|
||||||
await this.refreshSources();
|
await this.refreshSources();
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ export class PluginStoreService {
|
|||||||
this.loadingSignal.set(true);
|
this.loadingSignal.set(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensurePluginSourceReadRoots(this.sourceUrls());
|
||||||
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
|
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
|
||||||
|
|
||||||
if (this.refreshVersion === currentRefresh) {
|
if (this.refreshVersion === currentRefresh) {
|
||||||
@@ -292,7 +297,13 @@ export class PluginStoreService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
|
const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl;
|
||||||
|
|
||||||
|
if (sourcePath?.startsWith('file://')) {
|
||||||
|
await this.ensurePluginSourceReadRoot(sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.registerLocalManifest(installedPlugin.manifest, sourcePath);
|
||||||
|
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||||
|
|
||||||
@@ -503,6 +514,10 @@ export class PluginStoreService {
|
|||||||
return await this.readLocalFileUrl(url);
|
return await this.readLocalFileUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('https://')) {
|
||||||
|
throw new Error('Remote plugin store requests must use HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { headers: { Accept: accept }, signal });
|
const response = await fetch(url, { headers: { Accept: accept }, signal });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -512,6 +527,28 @@ export class PluginStoreService {
|
|||||||
return await response.text();
|
return await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async bootstrapSourceRefresh(sourceUrls: readonly string[]): Promise<void> {
|
||||||
|
await this.ensurePluginSourceReadRoots(sourceUrls);
|
||||||
|
|
||||||
|
if (this.stateMutated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePluginSourceReadRoots(sourceUrls: readonly string[]): Promise<void> {
|
||||||
|
await Promise.all(sourceUrls.map((sourceUrl) => this.ensurePluginSourceReadRoot(sourceUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePluginSourceReadRoot(sourceUrl: string): Promise<void> {
|
||||||
|
if (!sourceUrl.startsWith('file://')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await grantPluginReadRoots(this.electronBridge.getApi(), sourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private async readLocalFileUrl(fileUrl: string): Promise<string> {
|
private async readLocalFileUrl(fileUrl: string): Promise<string> {
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
@@ -519,7 +556,13 @@ export class PluginStoreService {
|
|||||||
throw new Error('Local plugin source paths require the desktop app');
|
throw new Error('Local plugin source paths require the desktop app');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.ensurePluginSourceReadRoot(fileUrl);
|
||||||
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
|
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
|
||||||
|
|
||||||
|
if (!base64Data) {
|
||||||
|
throw new Error(`Local plugin source is not readable: ${fileUrlToPath(fileUrl)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
@@ -874,7 +917,6 @@ export class PluginStoreService {
|
|||||||
roomId,
|
roomId,
|
||||||
installedPlugin.manifest.id,
|
installedPlugin.manifest.id,
|
||||||
{
|
{
|
||||||
actorUserId,
|
|
||||||
installUrl: installedPlugin.installUrl,
|
installUrl: installedPlugin.installUrl,
|
||||||
manifest: installedPlugin.manifest,
|
manifest: installedPlugin.manifest,
|
||||||
reason: installedPlugin.manifest.description,
|
reason: installedPlugin.manifest.description,
|
||||||
@@ -893,7 +935,7 @@ export class PluginStoreService {
|
|||||||
throw new Error('Open a chat server before removing server-scoped plugins');
|
throw new Error('Open a chat server before removing server-scoped plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId));
|
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPluginApiBaseUrl(serverId: string): string {
|
private getPluginApiBaseUrl(serverId: string): string {
|
||||||
@@ -994,7 +1036,7 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
if (sourceUrlsChanged) {
|
if (sourceUrlsChanged) {
|
||||||
this.sourceUrlsSignal.set(normalized.sourceUrls);
|
this.sourceUrlsSignal.set(normalized.sourceUrls);
|
||||||
void this.refreshSources();
|
void this.bootstrapSourceRefresh(normalized.sourceUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
|
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
|
||||||
@@ -1400,17 +1442,6 @@ function localPathToFileUrl(filePath: string): string | undefined {
|
|||||||
.join('/')}`;
|
.join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileUrlToPath(fileUrl: string): string {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const decodedPath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
|
||||||
return decodedPath.slice(1).replace(/\//g, '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbsoluteLocalPath(filePath: string): boolean {
|
function isAbsoluteLocalPath(filePath: string): boolean {
|
||||||
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
|
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
collectPluginReadRoots,
|
||||||
|
fileUrlToPath,
|
||||||
|
pluginFileParentDir
|
||||||
|
} from './plugin-local-file.rules';
|
||||||
|
|
||||||
|
describe('plugin-local-file.rules', () => {
|
||||||
|
it('resolves linux file URLs to absolute paths', () => {
|
||||||
|
expect(fileUrlToPath('file:///home/ludde/Desktop/TestPlugin/plugin-source.json'))
|
||||||
|
.toBe('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects plugin read roots from source and entrypoint URLs', () => {
|
||||||
|
expect(collectPluginReadRoots(
|
||||||
|
'file:///home/ludde/Desktop/TestPlugin/plugin-source.json',
|
||||||
|
'file:///home/ludde/Desktop/TestPlugin/dist/main.js'
|
||||||
|
)).toEqual([
|
||||||
|
'/home/ludde/Desktop/TestPlugin',
|
||||||
|
'/home/ludde/Desktop/TestPlugin/dist'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats directory file URLs as their own read roots', () => {
|
||||||
|
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual([
|
||||||
|
'/home/ludde/Desktop/TestPlugin'
|
||||||
|
]);
|
||||||
|
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual([
|
||||||
|
'/home/ludde/Desktop/TestPlugin'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ElectronApi } from '../../../../core/platform/electron/electron-api.models';
|
||||||
|
|
||||||
|
export function pluginFileParentDir(filePath: string): string {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
const index = normalized.lastIndexOf('/');
|
||||||
|
|
||||||
|
return index > 0 ? normalized.slice(0, index) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginReadRootForFileUrl(fileUrl: string): string {
|
||||||
|
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
const basename = filePath.split('/').pop() ?? '';
|
||||||
|
|
||||||
|
if (fileUrl.endsWith('/') || !basename.includes('.')) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginFileParentDir(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPluginReadRoots(...fileUrls: Array<string | undefined>): string[] {
|
||||||
|
const roots = new Set<string>();
|
||||||
|
|
||||||
|
for (const fileUrl of fileUrls) {
|
||||||
|
if (!fileUrl?.startsWith('file://')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.add(pluginReadRootForFileUrl(fileUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...roots];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantPluginReadRoots(
|
||||||
|
api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined,
|
||||||
|
...fileUrls: Array<string | undefined>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!api?.grantPluginReadRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = collectPluginReadRoots(...fileUrls);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
await api.grantPluginReadRoot(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileUrlToPath(fileUrl: string): string {
|
||||||
|
const url = new URL(fileUrl);
|
||||||
|
const decodedPath = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
||||||
|
return decodedPath.slice(1).replace(/\//g, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedPath;
|
||||||
|
}
|
||||||
@@ -266,7 +266,6 @@ export class ServerDirectoryService {
|
|||||||
updateServer(
|
updateServer(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
updates: Partial<ServerInfo> & {
|
updates: Partial<ServerInfo> & {
|
||||||
currentOwnerId: string;
|
|
||||||
actingRole?: string;
|
actingRole?: string;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export interface ServerJoinAccessResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateServerInviteRequest {
|
export interface CreateServerInviteRequest {
|
||||||
requesterUserId: string;
|
|
||||||
requesterDisplayName?: string;
|
requesterDisplayName?: string;
|
||||||
requesterRole?: string;
|
requesterRole?: string;
|
||||||
}
|
}
|
||||||
@@ -116,8 +115,6 @@ export interface ServerInviteInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface KickServerMemberRequest {
|
export interface KickServerMemberRequest {
|
||||||
actorUserId: string;
|
|
||||||
actorRole?: string;
|
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +126,6 @@ export interface BanServerMemberRequest extends KickServerMemberRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UnbanServerMemberRequest {
|
export interface UnbanServerMemberRequest {
|
||||||
actorUserId: string;
|
|
||||||
actorRole?: string;
|
|
||||||
banId?: string;
|
banId?: string;
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ export class ServerDirectoryApiService {
|
|||||||
updateServer(
|
updateServer(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
updates: Partial<ServerInfo> & {
|
updates: Partial<ServerInfo> & {
|
||||||
currentOwnerId: string;
|
|
||||||
actingRole?: string;
|
actingRole?: string;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-semibold text-foreground">{{ 'dashboard.openInvite' | translate }}</p>
|
<p class="truncate text-sm font-semibold text-foreground">{{ 'dashboard.openInvite' | translate }}</p>
|
||||||
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
|
<p class="truncate text-xs text-muted-foreground">{{ invite.inviteId }}</p>
|
||||||
</div>
|
</div>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideArrowRight"
|
name="lucideArrowRight"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user