Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39eb7c77ad | |||
| 9d7f658fc0 | |||
| c9592a9cb0 | |||
| 76097f3294 | |||
| 6dbd5e77c4 | |||
| afbaa8f7d1 | |||
| 3acd082fb0 |
28 changed files with 3662 additions and 866 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Authentik OIDC app values
|
# Authentik OIDC app values
|
||||||
OIDC_CLIENT_ID=replace-me
|
OIDC_CLIENT_ID=replace-me
|
||||||
|
|
@ -16,13 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr
|
||||||
TURN_USERNAME=replace-me
|
TURN_USERNAME=replace-me
|
||||||
TURN_PASSWORD=replace-me
|
TURN_PASSWORD=replace-me
|
||||||
|
|
||||||
# 32+ random chars; used to sign session cookies
|
|
||||||
SESSION_SECRET=replace-with-long-random-secret
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
|
|
||||||
# Cloudflare R2 media uploads
|
# Cloudflare R2 media uploads
|
||||||
R2_ACCOUNT_ID=replace-me
|
R2_ACCOUNT_ID=replace-me
|
||||||
R2_ACCESS_KEY_ID=replace-me
|
R2_ACCESS_KEY_ID=replace-me
|
||||||
R2_SECRET_ACCESS_KEY=replace-me
|
R2_SECRET_ACCESS_KEY=replace-me
|
||||||
R2_BUCKET=chattz-media
|
R2_BUCKET=chattz-media
|
||||||
R2_PUBLIC_BASE_URL=https://media.example.com
|
MEDIA_BASE_URL=https://media.example.com
|
||||||
|
|
|
||||||
51
Cargo.lock
generated
51
Cargo.lock
generated
|
|
@ -675,13 +675,14 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonwebtoken",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"sea-orm-migration",
|
"sea-orm-migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1648,21 +1649,6 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsonwebtoken"
|
|
||||||
version = "9.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"js-sys",
|
|
||||||
"pem",
|
|
||||||
"ring",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"simple_asn1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -1825,16 +1811,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-bigint"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
|
||||||
dependencies = [
|
|
||||||
"num-integer",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -1967,16 +1943,6 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem"
|
|
||||||
version = "3.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -2765,18 +2731,6 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simple_asn1"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
|
||||||
dependencies = [
|
|
||||||
"num-bigint",
|
|
||||||
"num-traits",
|
|
||||||
"thiserror",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -3131,7 +3085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,15 @@ aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio",
|
||||||
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
jsonwebtoken = "9"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
|
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] }
|
||||||
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using:
|
||||||
## What this includes
|
## What this includes
|
||||||
|
|
||||||
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
|
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
|
||||||
- Signed session cookie auth
|
- HttpOnly session cookie auth
|
||||||
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
|
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
|
||||||
- Guild invite codes (create + join)
|
- Guild invite codes (create + join)
|
||||||
- Direct messages (DM) between users
|
- Direct messages (DM) between users
|
||||||
|
|
@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`:
|
||||||
- `TURN_USERNAME`
|
- `TURN_USERNAME`
|
||||||
- `TURN_PASSWORD`
|
- `TURN_PASSWORD`
|
||||||
|
|
||||||
|
For production deployments:
|
||||||
|
- `APP_BASE_URL` must be your public app origin and should use `https`
|
||||||
|
- `MEDIA_BASE_URL` should be a separate media origin for user uploads
|
||||||
|
- uploads and soundboard require R2/object storage to be configured
|
||||||
|
|
||||||
3. Run app:
|
3. Run app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`.
|
||||||
## Authentik setup notes
|
## Authentik setup notes
|
||||||
|
|
||||||
Create an Authentik OAuth2/OIDC provider + application and set:
|
Create an Authentik OAuth2/OIDC provider + application and set:
|
||||||
- Redirect URI: `http://localhost:3000/auth/callback`
|
- Redirect URI: `${APP_BASE_URL}/auth/callback`
|
||||||
- Scopes including at least: `openid profile email`
|
- Scopes including at least: `openid profile email`
|
||||||
|
|
||||||
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
|
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
|
||||||
|
|
@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug.
|
||||||
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
|
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
|
||||||
|
|
||||||
All endpoints except health and auth flow require the session cookie from successful login.
|
All endpoints except health and auth flow require the session cookie from successful login.
|
||||||
|
Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in
|
||||||
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
|
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
|
||||||
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
|
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
|
||||||
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
|
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
|
||||||
|
User uploads are served from the configured media origin, not from `/static`.
|
||||||
|
|
||||||
Mic filter modes in the UI:
|
Mic filter modes in the UI:
|
||||||
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)
|
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Chattz</title>
|
<title>Chattz</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../static/styles.css" />
|
<link rel="stylesheet" href="../static/styles.css" />
|
||||||
<!-- Lucide Icons -->
|
<!-- Lucide Icons -->
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="../static/vendor/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
function onIpc(channel, callback) {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text),
|
copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text),
|
||||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
|
||||||
storageGet: (key) => ipcRenderer.invoke('storage-get', key),
|
|
||||||
storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
|
||||||
storageRemove: (key) => ipcRenderer.invoke('storage-remove', key),
|
|
||||||
getUpdateState: () => ipcRenderer.invoke('get-update-state'),
|
getUpdateState: () => ipcRenderer.invoke('get-update-state'),
|
||||||
checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'),
|
checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'),
|
||||||
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||||
downloadUpdate: () => ipcRenderer.send('download-update'),
|
downloadUpdate: () => ipcRenderer.send('download-update'),
|
||||||
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
||||||
onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)),
|
onUpdateState: (callback) => onIpc('update-state', callback),
|
||||||
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)),
|
onUpdateAvailable: (callback) => onIpc('update-available', callback),
|
||||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)),
|
onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback),
|
||||||
onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error))
|
onUpdateError: (callback) => onIpc('update-error', callback)
|
||||||
});
|
});
|
||||||
|
|
|
||||||
237
main.js
237
main.js
|
|
@ -1,11 +1,11 @@
|
||||||
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
|
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
|
||||||
const { autoUpdater } = require('electron-updater');
|
const { autoUpdater } = require('electron-updater');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const packageJson = require('./package.json');
|
||||||
const url = require('url');
|
const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000;
|
||||||
|
|
||||||
const updateState = {
|
const updateState = {
|
||||||
status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error
|
status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error
|
||||||
info: null,
|
info: null,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -19,41 +19,43 @@ function broadcastUpdateState() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStorageFilePath() {
|
|
||||||
return path.join(app.getPath('userData'), 'renderer-storage.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersistentStore() {
|
|
||||||
const filePath = getStorageFilePath();
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(filePath)) return {};
|
|
||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to read persistent store', err);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePersistentStore(store) {
|
|
||||||
const filePath = getStorageFilePath();
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(store), 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to write persistent store', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNewerVersionAvailable(info) {
|
function isNewerVersionAvailable(info) {
|
||||||
const next = info && typeof info.version === 'string' ? info.version : '';
|
const next = info && typeof info.version === 'string' ? info.version : '';
|
||||||
return Boolean(next) && next !== app.getVersion();
|
return Boolean(next) && next !== app.getVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
let updateCheckInProgress = false;
|
let updateCheckInProgress = false;
|
||||||
|
let installInProgress = false;
|
||||||
|
let periodicUpdateTimer = null;
|
||||||
|
|
||||||
|
function resolveBackendUrl() {
|
||||||
|
const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim();
|
||||||
|
if (configuredUrl) {
|
||||||
|
return configuredUrl.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagedFallback = typeof packageJson.homepage === 'string'
|
||||||
|
? packageJson.homepage.trim()
|
||||||
|
: '';
|
||||||
|
if (app.isPackaged && packagedFallback) {
|
||||||
|
return packagedFallback.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webContentsOrigin(webContents) {
|
||||||
|
if (!webContents || typeof webContents.getURL !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return safeOrigin(webContents.getURL());
|
||||||
|
}
|
||||||
|
|
||||||
async function runUpdateCheck(reason = 'manual') {
|
async function runUpdateCheck(reason = 'manual') {
|
||||||
|
if (installInProgress) {
|
||||||
|
console.log(`Update check skipped (${reason}): install already in progress`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (updateCheckInProgress) {
|
if (updateCheckInProgress) {
|
||||||
console.log(`Update check skipped (${reason}): another check is already in progress`);
|
console.log(`Update check skipped (${reason}): another check is already in progress`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -83,9 +85,62 @@ async function runUpdateCheck(reason = 'manual') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installDownloadedUpdate() {
|
||||||
|
if (installInProgress) {
|
||||||
|
console.log('Update install already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installInProgress = true;
|
||||||
|
updateState.status = 'installing';
|
||||||
|
updateState.error = null;
|
||||||
|
broadcastUpdateState();
|
||||||
|
|
||||||
|
const wins = BrowserWindow.getAllWindows();
|
||||||
|
for (const win of wins) {
|
||||||
|
if (win.isDestroyed()) continue;
|
||||||
|
try {
|
||||||
|
win.removeAllListeners('close');
|
||||||
|
win.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to destroy window before update install', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
autoUpdater.quitAndInstall(false, true);
|
||||||
|
} catch (err) {
|
||||||
|
installInProgress = false;
|
||||||
|
updateState.status = 'error';
|
||||||
|
updateState.error = err && err.message ? err.message : String(err);
|
||||||
|
broadcastUpdateState();
|
||||||
|
console.error('quitAndInstall failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (installInProgress) {
|
||||||
|
console.warn('Update install is still waiting for app shutdown');
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPeriodicUpdateChecks() {
|
||||||
|
if (periodicUpdateTimer || !app.isPackaged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
periodicUpdateTimer = setInterval(() => {
|
||||||
|
void runUpdateCheck('periodic');
|
||||||
|
}, PERIODIC_UPDATE_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create a persistent session for chattz to keep the user logged in
|
|
||||||
const sess = session.fromPartition('persist:chattz');
|
const sess = session.fromPartition('persist:chattz');
|
||||||
|
const backendUrl = resolveBackendUrl();
|
||||||
|
const backendOrigin = new URL(backendUrl).origin;
|
||||||
|
console.log(`Desktop backend URL: ${backendUrl}`);
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
|
|
@ -94,7 +149,6 @@ function createWindow() {
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies
|
|
||||||
session: sess,
|
session: sess,
|
||||||
preload: path.join(__dirname, 'desktop', 'preload.js')
|
preload: path.join(__dirname, 'desktop', 'preload.js')
|
||||||
}
|
}
|
||||||
|
|
@ -103,32 +157,33 @@ function createWindow() {
|
||||||
win.setAutoHideMenuBar(true);
|
win.setAutoHideMenuBar(true);
|
||||||
win.setMenuBarVisibility(true);
|
win.setMenuBarVisibility(true);
|
||||||
|
|
||||||
// Auto-approve media permissions (camera, microphone)
|
|
||||||
sess.setPermissionCheckHandler((webContents, permission) => {
|
sess.setPermissionCheckHandler((webContents, permission) => {
|
||||||
if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') {
|
const origin = webContentsOrigin(webContents);
|
||||||
return true;
|
if (origin !== backendOrigin) return false;
|
||||||
}
|
return permission === 'media' || permission === 'clipboard-write';
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') {
|
const origin = webContentsOrigin(webContents);
|
||||||
|
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
|
||||||
callback(true);
|
callback(true);
|
||||||
} else {
|
} else {
|
||||||
callback(false);
|
callback(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle screen share requests
|
|
||||||
sess.setDisplayMediaRequestHandler((request, callback) => {
|
sess.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
|
const origin = safeOrigin(request.frame?.url || win.webContents.getURL());
|
||||||
|
if (origin !== backendOrigin) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
||||||
// Provide the first screen source by default, or implement a picker window here
|
|
||||||
if (sources && sources.length > 0) {
|
if (sources && sources.length > 0) {
|
||||||
// We prefer a screen over a window if available, simple heuristic
|
|
||||||
const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0];
|
const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0];
|
||||||
callback({ video: screenSource, audio: 'loopback' });
|
callback({ video: screenSource, audio: 'loopback' });
|
||||||
} else {
|
} else {
|
||||||
callback(null); // Reject safely
|
callback(null);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error("Failed to get desktop sources for screen share", err);
|
console.error("Failed to get desktop sources for screen share", err);
|
||||||
|
|
@ -136,55 +191,9 @@ function createWindow() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '');
|
win.loadURL(backendUrl).catch((err) => {
|
||||||
const indexPath = path.join(__dirname, 'desktop', 'index.html');
|
console.error(`Failed to load desktop app: ${err}`);
|
||||||
|
|
||||||
const loadDesktopApp = (queryParams = '') => {
|
|
||||||
const options = {};
|
|
||||||
if (queryParams) {
|
|
||||||
try {
|
|
||||||
options.query = Object.fromEntries(new URLSearchParams(queryParams));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse query params", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use setImmediate to ensure the current navigation tick is cleared,
|
|
||||||
// which prevents ERR_ABORTED (-3) on some platforms when interrupting a redirect.
|
|
||||||
setImmediate(() => {
|
|
||||||
if (win.isDestroyed()) return;
|
|
||||||
win.loadFile(indexPath, options).catch((err) => {
|
|
||||||
// Ignore aborted errors as they often happen during fast redirects
|
|
||||||
if (err.toString().includes('-3') || err.code === -3) return;
|
|
||||||
console.error(`Failed to load desktop file: ${err}`);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Intercept navigations/redirects that return to the backend with a token
|
|
||||||
// after the OAuth login flow. Two handlers are needed because:
|
|
||||||
// - will-navigate fires for client-side navigations (location.href, link clicks)
|
|
||||||
// - will-redirect fires for server-side 302 redirects (OAuth callback chain)
|
|
||||||
const interceptLoginRedirect = (event, navigatedUrl) => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(navigatedUrl);
|
|
||||||
const backendObj = new URL(backendUrl);
|
|
||||||
if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') {
|
|
||||||
if (urlObj.searchParams.has('token')) {
|
|
||||||
console.log("Detected login success redirect, returning to desktop UI...");
|
|
||||||
event.preventDefault();
|
|
||||||
loadDesktopApp(urlObj.search.slice(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
win.webContents.on('will-navigate', interceptLoginRedirect);
|
|
||||||
win.webContents.on('will-redirect', interceptLoginRedirect);
|
|
||||||
|
|
||||||
loadDesktopApp();
|
|
||||||
|
|
||||||
// Ensure renderer receives latest updater state after any (re)load.
|
// Ensure renderer receives latest updater state after any (re)load.
|
||||||
// Delay broadcast by 300ms to give the renderer time to register its
|
// Delay broadcast by 300ms to give the renderer time to register its
|
||||||
|
|
@ -203,40 +212,11 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re
|
||||||
app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues)
|
app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues)
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
// Register IPC handlers once (before creating any windows)
|
|
||||||
ipcMain.handle('get-config', () => {
|
|
||||||
return {
|
|
||||||
backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('clipboard-write', (event, text) => {
|
ipcMain.handle('clipboard-write', (event, text) => {
|
||||||
clipboard.writeText(text);
|
clipboard.writeText(text);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage-get', (event, key) => {
|
|
||||||
if (typeof key !== 'string' || key.length === 0) return null;
|
|
||||||
const store = readPersistentStore();
|
|
||||||
return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('storage-set', (event, key, value) => {
|
|
||||||
if (typeof key !== 'string' || key.length === 0) return false;
|
|
||||||
const store = readPersistentStore();
|
|
||||||
store[key] = value;
|
|
||||||
writePersistentStore(store);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('storage-remove', (event, key) => {
|
|
||||||
if (typeof key !== 'string' || key.length === 0) return false;
|
|
||||||
const store = readPersistentStore();
|
|
||||||
delete store[key];
|
|
||||||
writePersistentStore(store);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('get-update-state', () => ({ ...updateState }));
|
ipcMain.handle('get-update-state', () => ({ ...updateState }));
|
||||||
ipcMain.handle('check-for-updates-now', async () => {
|
ipcMain.handle('check-for-updates-now', async () => {
|
||||||
await runUpdateCheck('renderer-direct');
|
await runUpdateCheck('renderer-direct');
|
||||||
|
|
@ -287,6 +267,7 @@ app.whenReady().then(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('download-update', () => {
|
ipcMain.on('download-update', () => {
|
||||||
|
if (installInProgress) return;
|
||||||
updateState.status = 'downloading';
|
updateState.status = 'downloading';
|
||||||
updateState.error = null;
|
updateState.error = null;
|
||||||
broadcastUpdateState();
|
broadcastUpdateState();
|
||||||
|
|
@ -294,13 +275,14 @@ app.whenReady().then(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('quit-and-install', () => {
|
ipcMain.on('quit-and-install', () => {
|
||||||
autoUpdater.quitAndInstall();
|
installDownloadedUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check once on startup
|
// Check once on startup
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void runUpdateCheck('startup');
|
void runUpdateCheck('startup');
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
startPeriodicUpdateChecks();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|
@ -314,3 +296,18 @@ app.on('window-all-closed', () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
if (periodicUpdateTimer) {
|
||||||
|
clearInterval(periodicUpdateTimer);
|
||||||
|
periodicUpdateTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function safeOrigin(value) {
|
||||||
|
try {
|
||||||
|
return new URL(value).origin;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,30 @@ const root = path.resolve(__dirname, '..');
|
||||||
const templatePath = path.join(root, 'shared-html', 'index.template.html');
|
const templatePath = path.join(root, 'shared-html', 'index.template.html');
|
||||||
const template = fs.readFileSync(templatePath, 'utf8');
|
const template = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
|
||||||
|
const updaterMarkup = `<div id="update-notifier" class="update-notifier hidden">
|
||||||
|
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
||||||
|
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
web: {
|
web: {
|
||||||
styles_href: '/static/styles.css',
|
styles_href: '/static/styles.css',
|
||||||
|
lucide_src: '/static/vendor/lucide.min.js',
|
||||||
app_src: '/static/app.js?v=20260227-shared-core-1',
|
app_src: '/static/app.js?v=20260227-shared-core-1',
|
||||||
web_downloads: `<div class="auth-downloads">
|
web_downloads: `<div class="auth-downloads">
|
||||||
<span>Desktop downloads:</span>
|
<span>Desktop downloads:</span>
|
||||||
<a href="/static/installers/chattz-windows.exe">Windows</a>
|
<a href="/static/installers/chattz-windows.exe">Windows</a>
|
||||||
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
|
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
|
||||||
</div>`,
|
</div>`,
|
||||||
desktop_updater: '',
|
desktop_updater: updaterMarkup,
|
||||||
outPath: path.join(root, 'static', 'index.html'),
|
outPath: path.join(root, 'static', 'index.html'),
|
||||||
},
|
},
|
||||||
desktop: {
|
desktop: {
|
||||||
styles_href: '../static/styles.css',
|
styles_href: '../static/styles.css',
|
||||||
|
lucide_src: '../static/vendor/lucide.min.js',
|
||||||
app_src: 'app.js?v=20260227-shared-core-1',
|
app_src: 'app.js?v=20260227-shared-core-1',
|
||||||
web_downloads: '',
|
web_downloads: '',
|
||||||
desktop_updater: `<div id="update-notifier" class="update-notifier hidden">
|
desktop_updater: updaterMarkup,
|
||||||
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
|
||||||
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
|
||||||
</div>`,
|
|
||||||
outPath: path.join(root, 'desktop', 'index.html'),
|
outPath: path.join(root, 'desktop', 'index.html'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Chattz</title>
|
<title>Chattz</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="{{styles_href}}" />
|
<link rel="stylesheet" href="{{styles_href}}" />
|
||||||
<!-- Lucide Icons -->
|
<!-- Lucide Icons -->
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="{{lucide_src}}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
274
src/auth.rs
274
src/auth.rs
|
|
@ -1,33 +1,25 @@
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{FromRef, FromRequestParts},
|
extract::{FromRef, FromRequestParts},
|
||||||
http::{StatusCode, request::Parts},
|
http::{HeaderMap, StatusCode, request::Parts},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
use chrono::{Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{AppState, db};
|
use crate::{AppState, db};
|
||||||
|
|
||||||
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||||
const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes
|
pub const SESSION_COOKIE: &str = "chattz_session";
|
||||||
const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days
|
const SESSION_TTL_DAYS: i64 = 30;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct SessionClaims {
|
|
||||||
pub sub: String,
|
|
||||||
pub kind: String, // "access" or "refresh"
|
|
||||||
pub exp: usize,
|
|
||||||
pub iat: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub session_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -44,6 +36,13 @@ impl ApiError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn forbidden(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
message: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bad_request(msg: &str) -> Self {
|
pub fn bad_request(msg: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
status: StatusCode::BAD_REQUEST,
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
|
@ -57,6 +56,13 @@ impl ApiError {
|
||||||
message: msg.to_string(),
|
message: msg.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn service_unavailable(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
message: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -84,29 +90,25 @@ impl From<anyhow::Error> for ApiError {
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for AuthUser
|
impl<S> FromRequestParts<S> for AuthUser
|
||||||
where
|
where
|
||||||
AppState: axum::extract::FromRef<S>,
|
AppState: FromRef<S>,
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
{
|
{
|
||||||
type Rejection = ApiError;
|
type Rejection = ApiError;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let app = AppState::from_ref(state);
|
let app = AppState::from_ref(state);
|
||||||
|
let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE)
|
||||||
|
.ok_or_else(|| ApiError::unauthorized("missing session cookie"))?;
|
||||||
|
|
||||||
let token = read_bearer_token(parts)
|
let user_id = db::touch_active_session(&app.db, &session_id)
|
||||||
.or_else(|| read_query_token(parts))
|
|
||||||
.ok_or_else(|| ApiError::unauthorized("missing jwt token"))?;
|
|
||||||
|
|
||||||
let user_id = verify_session(&token, &app.settings.session_secret, "access")
|
|
||||||
.map_err(|_| ApiError::unauthorized("invalid or expired token"))?;
|
|
||||||
|
|
||||||
let exists = db::user_exists(&app.db, user_id)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::unauthorized("session user not found"))?;
|
.map_err(|_| ApiError::unauthorized("invalid or expired session"))?
|
||||||
if !exists {
|
.ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?;
|
||||||
return Err(ApiError::unauthorized("session user not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self { id: user_id })
|
Ok(Self {
|
||||||
|
id: user_id,
|
||||||
|
session_id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +116,14 @@ pub fn new_oauth_state() -> String {
|
||||||
Uuid::new_v4().to_string()
|
Uuid::new_v4().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_session_id() -> String {
|
||||||
|
Uuid::new_v4().simple().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_expiry() -> chrono::DateTime<chrono::FixedOffset> {
|
||||||
|
(Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
|
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
|
||||||
|
|
@ -123,76 +133,32 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_oauth_state_cookie(secure: bool) -> String {
|
pub fn clear_oauth_state_cookie(secure: bool) -> String {
|
||||||
|
clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_session_cookie(value: &str, secure: bool) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
"{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}",
|
||||||
name = OAUTH_STATE_COOKIE,
|
name = SESSION_COOKIE,
|
||||||
|
ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(),
|
||||||
secure_flag = if secure { "; Secure" } else { "" }
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
pub fn clear_session_cookie(secure: bool) -> String {
|
||||||
|
clear_cookie(SESSION_COOKIE, secure, "Strict")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||||
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||||
raw.split(';').find_map(|pair| {
|
raw.split(';').find_map(|pair| {
|
||||||
let mut kv = pair.trim().splitn(2, '=');
|
let mut kv = pair.trim().splitn(2, '=');
|
||||||
let key = kv.next()?;
|
let key = kv.next()?;
|
||||||
let value = kv.next()?;
|
let value = kv.next()?;
|
||||||
(key == OAUTH_STATE_COOKIE).then(|| value.to_string())
|
(key == cookie_name).then(|| value.to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> {
|
|
||||||
let now = now_ts();
|
|
||||||
|
|
||||||
let access_claims = SessionClaims {
|
|
||||||
sub: user_id.to_string(),
|
|
||||||
kind: "access".to_string(),
|
|
||||||
iat: now as usize,
|
|
||||||
exp: (now + SESSION_TTL_SECS) as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
let refresh_claims = SessionClaims {
|
|
||||||
sub: user_id.to_string(),
|
|
||||||
kind: "refresh".to_string(),
|
|
||||||
iat: now as usize,
|
|
||||||
exp: (now + REFRESH_TTL_SECS) as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
let access_token = encode(
|
|
||||||
&Header::default(),
|
|
||||||
&access_claims,
|
|
||||||
&EncodingKey::from_secret(secret.as_bytes()),
|
|
||||||
)
|
|
||||||
.context("failed to encode access token")?;
|
|
||||||
|
|
||||||
let refresh_token = encode(
|
|
||||||
&Header::default(),
|
|
||||||
&refresh_claims,
|
|
||||||
&EncodingKey::from_secret(secret.as_bytes()),
|
|
||||||
)
|
|
||||||
.context("failed to encode refresh token")?;
|
|
||||||
|
|
||||||
Ok((access_token, refresh_token))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result<Uuid> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.validate_exp = true;
|
|
||||||
|
|
||||||
let data = decode::<SessionClaims>(
|
|
||||||
token,
|
|
||||||
&DecodingKey::from_secret(secret.as_bytes()),
|
|
||||||
&validation,
|
|
||||||
)
|
|
||||||
.context("failed to decode session token")?;
|
|
||||||
|
|
||||||
if data.claims.kind != expected_kind {
|
|
||||||
return Err(anyhow!("invalid token kind"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
|
||||||
Ok(user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
|
pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
|
||||||
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
|
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
|
||||||
if expected != query_state {
|
if expected != query_state {
|
||||||
|
|
@ -201,37 +167,113 @@ pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str)
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_bearer_token(parts: &Parts) -> Option<String> {
|
pub fn user_agent_hash(headers: &HeaderMap) -> Option<String> {
|
||||||
let raw = parts
|
header_hash(headers, axum::http::header::USER_AGENT.as_str())
|
||||||
.headers
|
}
|
||||||
.get(axum::http::header::AUTHORIZATION)?
|
|
||||||
.to_str()
|
pub fn ip_hash(headers: &HeaderMap) -> Option<String> {
|
||||||
.ok()?;
|
headers
|
||||||
if raw.starts_with("Bearer ") {
|
.get("x-forwarded-for")
|
||||||
Some(raw["Bearer ".len()..].trim().to_string())
|
.and_then(|v| v.to_str().ok())
|
||||||
} else {
|
.and_then(|raw| raw.split(',').next().map(str::trim))
|
||||||
None
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(hash_string)
|
||||||
|
.or_else(|| header_hash(headers, "x-real-ip"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool {
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::ORIGIN)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|origin| origin == expected_origin)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_hash(headers: &HeaderMap, header_name: &str) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(header_name)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(hash_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_string(value: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push(nibble_to_hex(byte >> 4));
|
||||||
|
out.push(nibble_to_hex(byte & 0x0f));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nibble_to_hex(value: u8) -> char {
|
||||||
|
match value {
|
||||||
|
0..=9 => (b'0' + value) as char,
|
||||||
|
10..=15 => (b'a' + (value - 10)) as char,
|
||||||
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_query_token(parts: &Parts) -> Option<String> {
|
fn clear_cookie(name: &str, secure: bool, same_site: &str) -> String {
|
||||||
let query = parts.uri.query()?;
|
format!(
|
||||||
|
"{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}",
|
||||||
// Simple query param parsing without pulling in url::Url overhead
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
for pair in query.split('&') {
|
)
|
||||||
let mut kv = pair.splitn(2, '=');
|
|
||||||
let key = kv.next()?;
|
|
||||||
let value = kv.next()?;
|
|
||||||
if key == "token" {
|
|
||||||
return Some(value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_ts() -> u64 {
|
#[cfg(test)]
|
||||||
SystemTime::now()
|
mod tests {
|
||||||
.duration_since(UNIX_EPOCH)
|
use super::{
|
||||||
.unwrap_or_else(|_| Duration::from_secs(0))
|
SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches,
|
||||||
.as_secs()
|
read_cookie_from_headers,
|
||||||
|
};
|
||||||
|
use axum::http::{HeaderMap, header};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_string_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
hash_string("example"),
|
||||||
|
"50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_named_cookie_from_header() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
"other=value; chattz_session=session-123; another=ok"
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_cookie_from_headers(&headers, SESSION_COOKIE),
|
||||||
|
Some("session-123".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origin_match_requires_exact_origin() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::ORIGIN, "http://localhost:3000".parse().unwrap());
|
||||||
|
|
||||||
|
assert!(origin_matches(&headers, "http://localhost:3000"));
|
||||||
|
assert!(!origin_matches(&headers, "https://localhost:3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_cookie_has_strict_policy_and_clear_cookie_expires() {
|
||||||
|
let session_cookie = make_session_cookie("session-123", false);
|
||||||
|
let cleared_cookie = clear_session_cookie(false);
|
||||||
|
|
||||||
|
assert!(session_cookie.contains("HttpOnly"));
|
||||||
|
assert!(session_cookie.contains("SameSite=Strict"));
|
||||||
|
assert!(session_cookie.contains("Max-Age="));
|
||||||
|
assert!(cleared_cookie.contains("SameSite=Strict"));
|
||||||
|
assert!(cleared_cookie.contains("Max-Age=0"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
291
src/chat.rs
291
src/chat.rs
|
|
@ -1,27 +1,30 @@
|
||||||
use crate::AppState;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocket};
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AppState, db};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ChatClient {
|
pub struct ChatClient {
|
||||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
is_idle: bool,
|
is_idle: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct OnlineUser {
|
pub struct OnlineUser {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
pub online: bool,
|
||||||
pub idle: bool,
|
pub idle: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ChatHub {
|
pub struct ChatHub {
|
||||||
// user_id -> client
|
// user_id -> connection_id -> client
|
||||||
clients: RwLock<HashMap<Uuid, ChatClient>>,
|
clients: RwLock<HashMap<Uuid, HashMap<Uuid, ChatClient>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -45,85 +48,113 @@ pub enum ServerEvent {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum ClientEvent {
|
enum ClientEvent {
|
||||||
// Currently no interactive client events for the general chat WS
|
|
||||||
Ping,
|
Ping,
|
||||||
SetIdleStatus { is_idle: bool },
|
SetIdleStatus { is_idle: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatHub {
|
impl ChatHub {
|
||||||
pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender<ServerEvent>) {
|
pub async fn add_client(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
|
) -> Option<OnlineUser> {
|
||||||
let mut clients = self.clients.write().await;
|
let mut clients = self.clients.write().await;
|
||||||
clients.insert(user_id, ChatClient { tx, is_idle: false });
|
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
|
|
||||||
|
clients
|
||||||
|
.entry(user_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(connection_id, ChatClient { tx, is_idle: false });
|
||||||
|
|
||||||
|
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
|
presence_delta(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_client(&self, user_id: Uuid) {
|
pub async fn remove_client(&self, user_id: Uuid, connection_id: Uuid) -> Option<OnlineUser> {
|
||||||
let mut clients = self.clients.write().await;
|
let mut clients = self.clients.write().await;
|
||||||
|
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
|
|
||||||
|
if let Some(connections) = clients.get_mut(&user_id) {
|
||||||
|
connections.remove(&connection_id);
|
||||||
|
if connections.is_empty() {
|
||||||
clients.remove(&user_id);
|
clients.remove(&user_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_online_users(&self) -> Vec<OnlineUser> {
|
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
|
presence_delta(previous, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec<OnlineUser> {
|
||||||
let clients = self.clients.read().await;
|
let clients = self.clients.read().await;
|
||||||
clients
|
visible_user_ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, client)| OnlineUser {
|
.filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id))
|
||||||
user_id: *id,
|
.filter(|presence| presence.online)
|
||||||
idle: client.is_idle,
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn broadcast_all(&self, event: ServerEvent) {
|
|
||||||
let clients = self.clients.read().await;
|
|
||||||
for client in clients.values() {
|
|
||||||
let _ = client.tx.send(event.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
|
|
||||||
let clients = self.clients.read().await;
|
|
||||||
if let Some(client) = clients.get(&user_id) {
|
|
||||||
let _ = client.tx.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
|
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
|
||||||
let clients = self.clients.read().await;
|
let clients = self.clients.read().await;
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
if let Some(client) = clients.get(&user_id) {
|
if let Some(connections) = clients.get(&user_id) {
|
||||||
|
for client in connections.values() {
|
||||||
|
let _ = client.tx.send(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
|
||||||
|
let clients = self.clients.read().await;
|
||||||
|
if let Some(connections) = clients.get(&user_id) {
|
||||||
|
for client in connections.values() {
|
||||||
let _ = client.tx.send(event.clone());
|
let _ = client.tx.send(event.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) {
|
pub async fn set_idle_status(
|
||||||
{
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
is_idle: bool,
|
||||||
|
) -> Option<OnlineUser> {
|
||||||
let mut clients = self.clients.write().await;
|
let mut clients = self.clients.write().await;
|
||||||
if let Some(client) = clients.get_mut(&user_id) {
|
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
|
|
||||||
|
if let Some(connections) = clients.get_mut(&user_id)
|
||||||
|
&& let Some(client) = connections.get_mut(&connection_id)
|
||||||
|
{
|
||||||
client.is_idle = is_idle;
|
client.is_idle = is_idle;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.broadcast_all(ServerEvent::UserPresence {
|
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||||
user_id,
|
presence_delta(previous, current)
|
||||||
online: true,
|
|
||||||
idle: is_idle,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
|
pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
|
||||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
|
||||||
state.chat.add_client(user_id, tx).await;
|
if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await {
|
||||||
|
if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await {
|
||||||
state
|
state
|
||||||
.chat
|
.chat
|
||||||
.broadcast_all(ServerEvent::UserPresence {
|
.broadcast_to_many(
|
||||||
user_id,
|
visible_user_ids,
|
||||||
online: true,
|
ServerEvent::UserPresence {
|
||||||
idle: false,
|
user_id: presence.user_id,
|
||||||
})
|
online: presence.online,
|
||||||
|
idle: presence.idle,
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let send_task = tokio::spawn(async move {
|
let send_task = tokio::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
|
|
@ -142,8 +173,24 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
|
||||||
Message::Text(text) => {
|
Message::Text(text) => {
|
||||||
if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
|
if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
|
||||||
serde_json::from_str::<ClientEvent>(&text)
|
serde_json::from_str::<ClientEvent>(&text)
|
||||||
|
&& let Some(presence) = state
|
||||||
|
.chat
|
||||||
|
.set_idle_status(user_id, connection_id, is_idle)
|
||||||
|
.await
|
||||||
|
&& let Ok(visible_user_ids) =
|
||||||
|
db::list_visible_user_ids(&state.db, user_id).await
|
||||||
{
|
{
|
||||||
state.chat.set_idle_status(user_id, is_idle).await;
|
state
|
||||||
|
.chat
|
||||||
|
.broadcast_to_many(
|
||||||
|
visible_user_ids,
|
||||||
|
ServerEvent::UserPresence {
|
||||||
|
user_id: presence.user_id,
|
||||||
|
online: presence.online,
|
||||||
|
idle: presence.idle,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -151,13 +198,157 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
|
||||||
}
|
}
|
||||||
|
|
||||||
send_task.abort();
|
send_task.abort();
|
||||||
state.chat.remove_client(user_id).await;
|
if let Some(presence) = state.chat.remove_client(user_id, connection_id).await
|
||||||
|
&& let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await
|
||||||
|
{
|
||||||
state
|
state
|
||||||
.chat
|
.chat
|
||||||
.broadcast_all(ServerEvent::UserPresence {
|
.broadcast_to_many(
|
||||||
|
visible_user_ids,
|
||||||
|
ServerEvent::UserPresence {
|
||||||
|
user_id: presence.user_id,
|
||||||
|
online: presence.online,
|
||||||
|
idle: presence.idle,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_presence(
|
||||||
|
connections: Option<&HashMap<Uuid, ChatClient>>,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Option<OnlineUser> {
|
||||||
|
let connections = connections?;
|
||||||
|
if connections.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idle = connections.values().all(|client| client.is_idle);
|
||||||
|
Some(OnlineUser {
|
||||||
|
user_id,
|
||||||
|
online: true,
|
||||||
|
idle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presence_delta(previous: Option<OnlineUser>, current: Option<OnlineUser>) -> Option<OnlineUser> {
|
||||||
|
match (previous, current) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(prev), Some(curr)) if prev == curr => None,
|
||||||
|
(Some(prev), None) => Some(OnlineUser {
|
||||||
|
user_id: prev.user_id,
|
||||||
|
online: false,
|
||||||
|
idle: false,
|
||||||
|
}),
|
||||||
|
(_, Some(curr)) => Some(curr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ChatHub, OnlineUser, ServerEvent};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_connections_keep_user_online_until_last_disconnect() {
|
||||||
|
let hub = ChatHub::default();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let first_presence = hub.add_client(user_id, first_connection, tx1).await;
|
||||||
|
let second_presence = hub.add_client(user_id, second_connection, tx2).await;
|
||||||
|
let after_first_disconnect = hub.remove_client(user_id, first_connection).await;
|
||||||
|
let after_last_disconnect = hub.remove_client(user_id, second_connection).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
first_presence,
|
||||||
|
Some(OnlineUser {
|
||||||
|
user_id,
|
||||||
|
online: true,
|
||||||
|
idle: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(second_presence, None);
|
||||||
|
assert_eq!(after_first_disconnect, None);
|
||||||
|
assert_eq!(
|
||||||
|
after_last_disconnect,
|
||||||
|
Some(OnlineUser {
|
||||||
user_id,
|
user_id,
|
||||||
online: false,
|
online: false,
|
||||||
idle: false,
|
idle: false,
|
||||||
})
|
})
|
||||||
.await;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_status_only_flips_when_all_connections_are_idle() {
|
||||||
|
let hub = ChatHub::default();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
hub.add_client(user_id, first_connection, tx1).await;
|
||||||
|
hub.add_client(user_id, second_connection, tx2).await;
|
||||||
|
|
||||||
|
let first_idle = hub.set_idle_status(user_id, first_connection, true).await;
|
||||||
|
let second_idle = hub.set_idle_status(user_id, second_connection, true).await;
|
||||||
|
let active_again = hub.set_idle_status(user_id, first_connection, false).await;
|
||||||
|
|
||||||
|
assert_eq!(first_idle, None);
|
||||||
|
assert_eq!(
|
||||||
|
second_idle,
|
||||||
|
Some(OnlineUser {
|
||||||
|
user_id,
|
||||||
|
online: true,
|
||||||
|
idle: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
active_again,
|
||||||
|
Some(OnlineUser {
|
||||||
|
user_id,
|
||||||
|
online: true,
|
||||||
|
idle: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn broadcast_to_user_reaches_all_active_connections() {
|
||||||
|
let hub = ChatHub::default();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let (tx1, mut rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
hub.add_client(user_id, first_connection, tx1).await;
|
||||||
|
hub.add_client(user_id, second_connection, tx2).await;
|
||||||
|
|
||||||
|
hub.broadcast_to_user(
|
||||||
|
user_id,
|
||||||
|
ServerEvent::DmCreated {
|
||||||
|
other_user_id: Uuid::new_v4(),
|
||||||
|
message: json!({ "body": "hello" }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
rx1.try_recv(),
|
||||||
|
Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
rx2.try_recv(),
|
||||||
|
Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
src/config.rs
128
src/config.rs
|
|
@ -1,8 +1,11 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use reqwest::Url;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
pub app_base_url: String,
|
||||||
|
pub app_origin: String,
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub oidc_client_id: String,
|
pub oidc_client_id: String,
|
||||||
pub oidc_client_secret: String,
|
pub oidc_client_secret: String,
|
||||||
|
|
@ -11,8 +14,7 @@ pub struct Settings {
|
||||||
pub oidc_userinfo_url: String,
|
pub oidc_userinfo_url: String,
|
||||||
pub oidc_redirect_url: String,
|
pub oidc_redirect_url: String,
|
||||||
pub oidc_scopes: String,
|
pub oidc_scopes: String,
|
||||||
pub session_secret: String,
|
pub session_cookie_secure: bool,
|
||||||
pub cookie_secure: bool,
|
|
||||||
pub stun_urls: Vec<String>,
|
pub stun_urls: Vec<String>,
|
||||||
pub turn_urls: Vec<String>,
|
pub turn_urls: Vec<String>,
|
||||||
pub turn_username: Option<String>,
|
pub turn_username: Option<String>,
|
||||||
|
|
@ -31,11 +33,17 @@ pub struct MediaSettings {
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let app_base_url = required("APP_BASE_URL")?;
|
||||||
|
let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?;
|
||||||
|
let app_origin = origin_from_url(&app_url)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
port: std::env::var("PORT")
|
port: std::env::var("PORT")
|
||||||
.unwrap_or_else(|_| "3000".into())
|
.unwrap_or_else(|_| "3000".into())
|
||||||
.parse()
|
.parse()
|
||||||
.context("PORT must be a valid u16")?,
|
.context("PORT must be a valid u16")?,
|
||||||
|
app_base_url: trim_trailing_slash(&app_base_url),
|
||||||
|
app_origin,
|
||||||
database_url: required("DATABASE_URL")?,
|
database_url: required("DATABASE_URL")?,
|
||||||
oidc_client_id: required("OIDC_CLIENT_ID")?,
|
oidc_client_id: required("OIDC_CLIENT_ID")?,
|
||||||
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
|
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
|
||||||
|
|
@ -45,11 +53,7 @@ impl Settings {
|
||||||
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
|
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
|
||||||
oidc_scopes: std::env::var("OIDC_SCOPES")
|
oidc_scopes: std::env::var("OIDC_SCOPES")
|
||||||
.unwrap_or_else(|_| "openid profile email".to_string()),
|
.unwrap_or_else(|_| "openid profile email".to_string()),
|
||||||
session_secret: required("SESSION_SECRET")?,
|
session_cookie_secure: requires_secure_cookie(&app_url)?,
|
||||||
cookie_secure: std::env::var("COOKIE_SECURE")
|
|
||||||
.unwrap_or_else(|_| "false".into())
|
|
||||||
.parse()
|
|
||||||
.context("COOKIE_SECURE must be true/false")?,
|
|
||||||
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
|
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
|
||||||
turn_urls: parse_csv_env("TURN_URLS", ""),
|
turn_urls: parse_csv_env("TURN_URLS", ""),
|
||||||
turn_username: optional("TURN_USERNAME"),
|
turn_username: optional("TURN_USERNAME"),
|
||||||
|
|
@ -65,7 +69,7 @@ impl MediaSettings {
|
||||||
let access_key_id = optional("R2_ACCESS_KEY_ID");
|
let access_key_id = optional("R2_ACCESS_KEY_ID");
|
||||||
let secret_access_key = optional("R2_SECRET_ACCESS_KEY");
|
let secret_access_key = optional("R2_SECRET_ACCESS_KEY");
|
||||||
let bucket = optional("R2_BUCKET");
|
let bucket = optional("R2_BUCKET");
|
||||||
let public_base_url = optional("R2_PUBLIC_BASE_URL");
|
let public_base_url = optional("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL"));
|
||||||
|
|
||||||
if account_id.is_none()
|
if account_id.is_none()
|
||||||
&& access_key_id.is_none()
|
&& access_key_id.is_none()
|
||||||
|
|
@ -81,7 +85,9 @@ impl MediaSettings {
|
||||||
access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?,
|
access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?,
|
||||||
secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?,
|
secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?,
|
||||||
bucket: bucket.context("missing env var R2_BUCKET")?,
|
bucket: bucket.context("missing env var R2_BUCKET")?,
|
||||||
public_base_url: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?,
|
public_base_url: trim_trailing_slash(
|
||||||
|
&public_base_url.context("missing env var MEDIA_BASE_URL")?,
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,3 +115,105 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec<String> {
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn origin_from_url(url: &Url) -> Result<String> {
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
|
||||||
|
let mut origin = format!("{}://{}", url.scheme(), host);
|
||||||
|
if let Some(port) = url.port() {
|
||||||
|
origin.push(':');
|
||||||
|
origin.push_str(&port.to_string());
|
||||||
|
}
|
||||||
|
Ok(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_secure_cookie(url: &Url) -> Result<bool> {
|
||||||
|
match url.scheme() {
|
||||||
|
"https" => Ok(true),
|
||||||
|
"http" => {
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
|
||||||
|
if matches!(host, "localhost" | "127.0.0.1" | "::1") {
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"APP_BASE_URL must use https outside localhost when session cookies are enabled"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => Err(anyhow!("APP_BASE_URL scheme {other} is not supported")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_trailing_slash(value: &str) -> String {
|
||||||
|
value.trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{MediaSettings, origin_from_url, requires_secure_cookie, trim_trailing_slash};
|
||||||
|
use reqwest::Url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origin_from_url_preserves_explicit_port() {
|
||||||
|
let url = Url::parse("https://chat.example.com:8443/app").unwrap();
|
||||||
|
let origin = origin_from_url(&url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(origin, "https://chat.example.com:8443");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_cookie_is_required_for_https_origins() {
|
||||||
|
let url = Url::parse("https://chat.example.com").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(requires_secure_cookie(&url).unwrap(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localhost_http_is_allowed_without_secure_cookie() {
|
||||||
|
let url = Url::parse("http://localhost:3000").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(requires_secure_cookie(&url).unwrap(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_localhost_http_is_rejected() {
|
||||||
|
let url = Url::parse("http://chat.example.com").unwrap();
|
||||||
|
let err = requires_secure_cookie(&url).unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("APP_BASE_URL must use https outside localhost")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_trailing_slash_removes_only_suffix_slashes() {
|
||||||
|
assert_eq!(
|
||||||
|
trim_trailing_slash("https://chat.example.com///"),
|
||||||
|
"https://chat.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trim_trailing_slash("https://chat.example.com/app"),
|
||||||
|
"https://chat.example.com/app"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_endpoint_url_uses_account_id() {
|
||||||
|
let media = MediaSettings {
|
||||||
|
account_id: "acct123".to_string(),
|
||||||
|
access_key_id: "key".to_string(),
|
||||||
|
secret_access_key: "secret".to_string(),
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
public_base_url: "https://media.example.com".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
media.endpoint_url(),
|
||||||
|
"https://acct123.r2.cloudflarestorage.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
478
src/db.rs
478
src/db.rs
|
|
@ -1,15 +1,17 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection,
|
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection,
|
||||||
DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
TransactionTrait, sea_query::OnConflict,
|
Statement, TransactionTrait, sea_query::OnConflict,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
entity::{
|
entity::{
|
||||||
attachments, channels, direct_messages, guild_members, guilds, invites, messages,
|
attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions,
|
||||||
soundboard_sounds, users,
|
soundboard_sounds, users,
|
||||||
},
|
},
|
||||||
models::{
|
models::{
|
||||||
|
|
@ -26,6 +28,88 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result<bool>
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_session(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
expires_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
user_agent_hash: Option<String>,
|
||||||
|
ip_hash: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sessions::Entity::insert(sessions::ActiveModel {
|
||||||
|
id: Set(session_id.to_string()),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
expires_at: Set(expires_at),
|
||||||
|
created_at: Set(Utc::now().fixed_offset()),
|
||||||
|
last_seen_at: Set(Utc::now().fixed_offset()),
|
||||||
|
revoked_at: Set(None),
|
||||||
|
user_agent_hash: Set(user_agent_hash),
|
||||||
|
ip_hash: Set(ip_hash),
|
||||||
|
})
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn touch_active_session(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<Option<Uuid>> {
|
||||||
|
let session = sessions::Entity::find_by_id(session_id.to_string())
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(session) = session else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if session.revoked_at.is_some() || session.expires_at <= Utc::now().fixed_offset() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = session.user_id;
|
||||||
|
sessions::Entity::update_many()
|
||||||
|
.col_expr(
|
||||||
|
sessions::Column::LastSeenAt,
|
||||||
|
sea_orm::sea_query::Expr::value(Utc::now().fixed_offset()),
|
||||||
|
)
|
||||||
|
.filter(sessions::Column::Id.eq(session_id.to_string()))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(Some(user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_session(db: &DatabaseConnection, session_id: &str) -> Result<()> {
|
||||||
|
if let Some(session) = sessions::Entity::find_by_id(session_id.to_string())
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
sessions::Entity::update_many()
|
||||||
|
.col_expr(
|
||||||
|
sessions::Column::RevokedAt,
|
||||||
|
sea_orm::sea_query::Expr::value(Some(Utc::now().fixed_offset())),
|
||||||
|
)
|
||||||
|
.filter(sessions::Column::Id.eq(session.id))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup_sessions(db: &DatabaseConnection) -> Result<()> {
|
||||||
|
sessions::Entity::delete_many()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(sessions::Column::ExpiresAt.lte(Utc::now().fixed_offset()))
|
||||||
|
.add(sessions::Column::RevokedAt.is_not_null()),
|
||||||
|
)
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_user_from_oidc(
|
pub async fn upsert_user_from_oidc(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
oidc_sub: &str,
|
oidc_sub: &str,
|
||||||
|
|
@ -106,18 +190,58 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_visible_user_ids(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||||
|
let guild_ids: Vec<Uuid> = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::UserId.eq(user_id))
|
||||||
|
.select_only()
|
||||||
|
.column(guild_members::Column::GuildId)
|
||||||
|
.into_tuple()
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut visible = HashSet::from([user_id]);
|
||||||
|
|
||||||
|
if !guild_ids.is_empty() {
|
||||||
|
let guild_users = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::GuildId.is_in(guild_ids))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
visible.extend(guild_users.into_iter().map(|membership| membership.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dm_rows = direct_messages::Entity::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(direct_messages::Column::SenderUserId.eq(user_id))
|
||||||
|
.add(direct_messages::Column::RecipientUserId.eq(user_id)),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in dm_rows {
|
||||||
|
if row.sender_user_id == user_id {
|
||||||
|
visible.insert(row.recipient_user_id);
|
||||||
|
} else {
|
||||||
|
visible.insert(row.sender_user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(visible.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_guild(
|
pub async fn create_guild(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
owner_user_id: Uuid,
|
owner_user_id: Uuid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Guild> {
|
) -> Result<Guild> {
|
||||||
|
let txn = db.begin().await?;
|
||||||
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
owner_user_id: Set(owner_user_id),
|
owner_user_id: Set(owner_user_id),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec_with_returning(db)
|
.exec_with_returning(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
guild_members::Entity::insert(guild_members::ActiveModel {
|
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||||
|
|
@ -133,17 +257,13 @@ pub async fn create_guild(
|
||||||
.do_nothing()
|
.do_nothing()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.exec(db)
|
.exec(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
Ok(map_guild(guild))
|
Ok(map_guild(guild))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_guild_by_id(db: &DatabaseConnection, guild_id: Uuid) -> Result<Option<Guild>> {
|
|
||||||
let row = guilds::Entity::find_by_id(guild_id).one(db).await?;
|
|
||||||
Ok(row.map(map_guild))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_guild_owner(
|
pub async fn is_guild_owner(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
guild_id: Uuid,
|
guild_id: Uuid,
|
||||||
|
|
@ -197,7 +317,12 @@ pub async fn create_invite(
|
||||||
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
|
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
let invite = invites::Entity::find_by_id(code.to_string())
|
let invite = invites::Entity::find()
|
||||||
|
.from_raw_sql(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#,
|
||||||
|
[code.into()],
|
||||||
|
))
|
||||||
.one(&txn)
|
.one(&txn)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow!("invite not found"))?;
|
.ok_or_else(|| anyhow!("invite not found"))?;
|
||||||
|
|
@ -797,7 +922,10 @@ pub async fn create_sound(
|
||||||
created_by_user_id: Uuid,
|
created_by_user_id: Uuid,
|
||||||
name: &str,
|
name: &str,
|
||||||
icon: &str,
|
icon: &str,
|
||||||
file_path: &str,
|
object_key: &str,
|
||||||
|
media_url: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
size_bytes: i64,
|
||||||
) -> Result<SoundboardSound> {
|
) -> Result<SoundboardSound> {
|
||||||
let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel {
|
let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
|
|
@ -805,7 +933,13 @@ pub async fn create_sound(
|
||||||
created_by_user_id: Set(created_by_user_id),
|
created_by_user_id: Set(created_by_user_id),
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
icon: Set(icon.to_string()),
|
icon: Set(icon.to_string()),
|
||||||
file_path: Set(file_path.to_string()),
|
object_key: Set(Some(object_key.to_string())),
|
||||||
|
media_url: Set(media_url.to_string()),
|
||||||
|
mime_type: Set(Some(mime_type.to_string())),
|
||||||
|
size_bytes: Set(Some(size_bytes)),
|
||||||
|
// Keep the legacy column populated until every deployment has applied
|
||||||
|
// the nullable migration and old fallback paths are fully removed.
|
||||||
|
file_path: Set(Some(media_url.to_string())),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec_with_returning(db)
|
.exec_with_returning(db)
|
||||||
|
|
@ -819,6 +953,11 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result<Option
|
||||||
Ok(row.map(map_sound))
|
Ok(row.map(map_sound))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_sound_object_key(db: &DatabaseConnection, id: Uuid) -> Result<Option<String>> {
|
||||||
|
let row = soundboard_sounds::Entity::find_by_id(id).one(db).await?;
|
||||||
|
Ok(row.and_then(|sound| sound.object_key))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_sound(db: &DatabaseConnection, id: Uuid) -> Result<()> {
|
pub async fn delete_sound(db: &DatabaseConnection, id: Uuid) -> Result<()> {
|
||||||
soundboard_sounds::Entity::delete_by_id(id).exec(db).await?;
|
soundboard_sounds::Entity::delete_by_id(id).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -830,8 +969,321 @@ fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound {
|
||||||
guild_id: model.guild_id,
|
guild_id: model.guild_id,
|
||||||
name: model.name,
|
name: model.name,
|
||||||
icon: model.icon,
|
icon: model.icon,
|
||||||
file_path: model.file_path,
|
media_url: model.media_url,
|
||||||
|
mime_type: model.mime_type,
|
||||||
|
size_bytes: model.size_bytes,
|
||||||
created_by_user_id: model.created_by_user_id,
|
created_by_user_id: model.created_by_user_id,
|
||||||
created_at: model.created_at,
|
created_at: model.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{create_guild, create_sound, join_invite, list_visible_user_ids, validate_invite};
|
||||||
|
use crate::entity::{direct_messages, guild_members, guilds, invites, soundboard_sounds};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Value};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn visible_users_include_self_shared_guilds_and_dm_partners() {
|
||||||
|
let current_user_id = Uuid::new_v4();
|
||||||
|
let guild_a = Uuid::new_v4();
|
||||||
|
let guild_b = Uuid::new_v4();
|
||||||
|
let guild_peer = Uuid::new_v4();
|
||||||
|
let shared_dm_peer = Uuid::new_v4();
|
||||||
|
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([vec![
|
||||||
|
BTreeMap::from([("guild_id".to_string(), Value::from(guild_a))]),
|
||||||
|
BTreeMap::from([("guild_id".to_string(), Value::from(guild_b))]),
|
||||||
|
]])
|
||||||
|
.append_query_results([vec![
|
||||||
|
guild_members::Model {
|
||||||
|
guild_id: guild_a,
|
||||||
|
user_id: current_user_id,
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
},
|
||||||
|
guild_members::Model {
|
||||||
|
guild_id: guild_b,
|
||||||
|
user_id: current_user_id,
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
},
|
||||||
|
guild_members::Model {
|
||||||
|
guild_id: guild_a,
|
||||||
|
user_id: guild_peer,
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
},
|
||||||
|
]])
|
||||||
|
.append_query_results([vec![
|
||||||
|
direct_messages::Model {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
sender_user_id: current_user_id,
|
||||||
|
recipient_user_id: shared_dm_peer,
|
||||||
|
body: "hello".to_string(),
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
},
|
||||||
|
direct_messages::Model {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
sender_user_id: shared_dm_peer,
|
||||||
|
recipient_user_id: current_user_id,
|
||||||
|
body: "hi".to_string(),
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
},
|
||||||
|
]])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let visible = list_visible_user_ids(&db, current_user_id).await.unwrap();
|
||||||
|
let visible: BTreeSet<_> = visible.into_iter().collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visible,
|
||||||
|
BTreeSet::from([current_user_id, guild_peer, shared_dm_peer])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn visible_users_returns_self_when_no_relationships_exist() {
|
||||||
|
let current_user_id = Uuid::new_v4();
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([Vec::<BTreeMap<String, Value>>::new()])
|
||||||
|
.append_query_results([Vec::<direct_messages::Model>::new()])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let visible = list_visible_user_ids(&db, current_user_id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(visible, vec![current_user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_invite_rejects_expired_invites() {
|
||||||
|
let invite = invites::Model {
|
||||||
|
code: "expired".to_string(),
|
||||||
|
guild_id: Uuid::new_v4(),
|
||||||
|
created_by_user_id: Uuid::new_v4(),
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
expires_at: Some((Utc::now() - Duration::minutes(1)).fixed_offset()),
|
||||||
|
max_uses: Some(5),
|
||||||
|
use_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = validate_invite(&invite).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invite expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_invite_rejects_exhausted_invites() {
|
||||||
|
let invite = invites::Model {
|
||||||
|
code: "used".to_string(),
|
||||||
|
guild_id: Uuid::new_v4(),
|
||||||
|
created_by_user_id: Uuid::new_v4(),
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()),
|
||||||
|
max_uses: Some(1),
|
||||||
|
use_count: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = validate_invite(&invite).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invite exhausted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_invite_accepts_active_invites() {
|
||||||
|
let invite = invites::Model {
|
||||||
|
code: "active".to_string(),
|
||||||
|
guild_id: Uuid::new_v4(),
|
||||||
|
created_by_user_id: Uuid::new_v4(),
|
||||||
|
created_at: Utc::now().fixed_offset(),
|
||||||
|
expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()),
|
||||||
|
max_uses: Some(3),
|
||||||
|
use_count: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
validate_invite(&invite).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_guild_runs_guild_and_membership_in_one_transaction() {
|
||||||
|
let owner_user_id = Uuid::new_v4();
|
||||||
|
let guild_id = Uuid::new_v4();
|
||||||
|
let created_at = Utc::now().fixed_offset();
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([vec![guilds::Model {
|
||||||
|
id: guild_id,
|
||||||
|
name: "Guild".to_string(),
|
||||||
|
owner_user_id,
|
||||||
|
created_at,
|
||||||
|
}]])
|
||||||
|
.append_exec_results([MockExecResult {
|
||||||
|
last_insert_id: 0,
|
||||||
|
rows_affected: 1,
|
||||||
|
}])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let guild = create_guild(&db, owner_user_id, "Guild").await.unwrap();
|
||||||
|
let transaction_log = format!("{:?}", db.into_transaction_log());
|
||||||
|
|
||||||
|
assert_eq!(guild.id, guild_id);
|
||||||
|
assert!(transaction_log.contains("BEGIN"), "{transaction_log}");
|
||||||
|
assert!(transaction_log.contains("guilds"), "{transaction_log}");
|
||||||
|
assert!(
|
||||||
|
transaction_log.contains("guild_members"),
|
||||||
|
"{transaction_log}"
|
||||||
|
);
|
||||||
|
assert!(transaction_log.contains("COMMIT"), "{transaction_log}");
|
||||||
|
|
||||||
|
let guild_insert = transaction_log.find("guilds");
|
||||||
|
let membership_insert = transaction_log.find("guild_members");
|
||||||
|
assert!(guild_insert.is_some() && membership_insert.is_some());
|
||||||
|
assert!(guild_insert.unwrap() < membership_insert.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn join_invite_locks_invite_and_updates_use_count_in_one_transaction() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let guild_id = Uuid::new_v4();
|
||||||
|
let created_by_user_id = Uuid::new_v4();
|
||||||
|
let created_at = Utc::now().fixed_offset();
|
||||||
|
let invite = invites::Model {
|
||||||
|
code: "invite123".to_string(),
|
||||||
|
guild_id,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()),
|
||||||
|
max_uses: Some(5),
|
||||||
|
use_count: 0,
|
||||||
|
};
|
||||||
|
let updated_invite = invites::Model {
|
||||||
|
use_count: 1,
|
||||||
|
..invite.clone()
|
||||||
|
};
|
||||||
|
let guild = guilds::Model {
|
||||||
|
id: guild_id,
|
||||||
|
name: "Guild".to_string(),
|
||||||
|
owner_user_id: created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([vec![invite]])
|
||||||
|
.append_query_results([Vec::<guild_members::Model>::new()])
|
||||||
|
.append_exec_results([MockExecResult {
|
||||||
|
last_insert_id: 0,
|
||||||
|
rows_affected: 1,
|
||||||
|
}])
|
||||||
|
.append_query_results([vec![updated_invite]])
|
||||||
|
.append_query_results([vec![guild]])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let joined_guild = join_invite(&db, "invite123", user_id).await.unwrap();
|
||||||
|
let transaction_log = format!("{:?}", db.into_transaction_log());
|
||||||
|
|
||||||
|
assert_eq!(joined_guild.id, guild_id);
|
||||||
|
assert!(transaction_log.contains("BEGIN"), "{transaction_log}");
|
||||||
|
assert!(transaction_log.contains("FOR UPDATE"), "{transaction_log}");
|
||||||
|
assert!(
|
||||||
|
transaction_log.contains("guild_members"),
|
||||||
|
"{transaction_log}"
|
||||||
|
);
|
||||||
|
assert!(transaction_log.contains("UPDATE"), "{transaction_log}");
|
||||||
|
assert!(transaction_log.contains("invites"), "{transaction_log}");
|
||||||
|
assert!(transaction_log.contains("COMMIT"), "{transaction_log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn join_invite_does_not_increment_use_count_for_existing_member() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let guild_id = Uuid::new_v4();
|
||||||
|
let created_by_user_id = Uuid::new_v4();
|
||||||
|
let created_at = Utc::now().fixed_offset();
|
||||||
|
let invite = invites::Model {
|
||||||
|
code: "invite123".to_string(),
|
||||||
|
guild_id,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()),
|
||||||
|
max_uses: Some(5),
|
||||||
|
use_count: 3,
|
||||||
|
};
|
||||||
|
let existing_member = guild_members::Model {
|
||||||
|
guild_id,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
};
|
||||||
|
let guild = guilds::Model {
|
||||||
|
id: guild_id,
|
||||||
|
name: "Guild".to_string(),
|
||||||
|
owner_user_id: created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([vec![invite]])
|
||||||
|
.append_query_results([vec![existing_member]])
|
||||||
|
.append_exec_results([MockExecResult {
|
||||||
|
last_insert_id: 0,
|
||||||
|
rows_affected: 1,
|
||||||
|
}])
|
||||||
|
.append_query_results([vec![guild]])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let joined_guild = join_invite(&db, "invite123", user_id).await.unwrap();
|
||||||
|
let transaction_log = format!("{:?}", db.into_transaction_log());
|
||||||
|
|
||||||
|
assert_eq!(joined_guild.id, guild_id);
|
||||||
|
assert!(transaction_log.contains("FOR UPDATE"), "{transaction_log}");
|
||||||
|
assert!(
|
||||||
|
transaction_log.contains("guild_members"),
|
||||||
|
"{transaction_log}"
|
||||||
|
);
|
||||||
|
assert!(transaction_log.contains("COMMIT"), "{transaction_log}");
|
||||||
|
assert!(
|
||||||
|
!transaction_log.contains("UPDATE \"invites\""),
|
||||||
|
"{transaction_log}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_sound_keeps_legacy_file_path_populated() {
|
||||||
|
let guild_id = Uuid::new_v4();
|
||||||
|
let sound_id = Uuid::new_v4();
|
||||||
|
let created_by_user_id = Uuid::new_v4();
|
||||||
|
let created_at = Utc::now().fixed_offset();
|
||||||
|
let media_url = "https://media.example.com/soundboard/test.mp3";
|
||||||
|
let db = MockDatabase::new(DatabaseBackend::Postgres)
|
||||||
|
.append_query_results([vec![soundboard_sounds::Model {
|
||||||
|
id: sound_id,
|
||||||
|
guild_id,
|
||||||
|
name: "Airhorn".to_string(),
|
||||||
|
icon: "AH".to_string(),
|
||||||
|
object_key: Some("soundboard/test.mp3".to_string()),
|
||||||
|
media_url: media_url.to_string(),
|
||||||
|
mime_type: Some("audio/mpeg".to_string()),
|
||||||
|
size_bytes: Some(1234),
|
||||||
|
file_path: Some(media_url.to_string()),
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
}]])
|
||||||
|
.into_connection();
|
||||||
|
|
||||||
|
let sound = create_sound(
|
||||||
|
&db,
|
||||||
|
guild_id,
|
||||||
|
created_by_user_id,
|
||||||
|
"Airhorn",
|
||||||
|
"AH",
|
||||||
|
"soundboard/test.mp3",
|
||||||
|
media_url,
|
||||||
|
"audio/mpeg",
|
||||||
|
1234,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let transaction_log = format!("{:?}", db.into_transaction_log());
|
||||||
|
|
||||||
|
assert_eq!(sound.id, sound_id);
|
||||||
|
assert!(transaction_log.contains("file_path"), "{transaction_log}");
|
||||||
|
assert!(transaction_log.contains(media_url), "{transaction_log}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@ pub mod guild_members;
|
||||||
pub mod guilds;
|
pub mod guilds;
|
||||||
pub mod invites;
|
pub mod invites;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod sessions;
|
||||||
pub mod soundboard_sounds;
|
pub mod soundboard_sounds;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
|
||||||
35
src/entity/sessions.rs
Normal file
35
src/entity/sessions.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "sessions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub expires_at: DateTimeWithTimeZone,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub last_seen_at: DateTimeWithTimeZone,
|
||||||
|
pub revoked_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub user_agent_hash: Option<String>,
|
||||||
|
pub ip_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
@ -9,7 +9,11 @@ pub struct Model {
|
||||||
pub guild_id: Uuid,
|
pub guild_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub file_path: String,
|
pub object_key: Option<String>,
|
||||||
|
pub media_url: String,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub size_bytes: Option<i64>,
|
||||||
|
pub file_path: Option<String>,
|
||||||
pub created_by_user_id: Uuid,
|
pub created_by_user_id: Uuid,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
417
src/handlers.rs
417
src/handlers.rs
|
|
@ -17,14 +17,12 @@ use crate::{
|
||||||
models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound},
|
models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound},
|
||||||
voice,
|
voice,
|
||||||
};
|
};
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/auth/login", get(auth_login))
|
.route("/auth/login", get(auth_login))
|
||||||
.route("/auth/callback", get(auth_callback))
|
.route("/auth/callback", get(auth_callback))
|
||||||
.route("/auth/refresh", post(auth_refresh))
|
|
||||||
.route("/auth/logout", post(auth_logout))
|
.route("/auth/logout", post(auth_logout))
|
||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
.route("/dms", get(list_dm_conversations))
|
.route("/dms", get(list_dm_conversations))
|
||||||
|
|
@ -94,7 +92,7 @@ async fn auth_login(State(state): State<AppState>) -> Result<impl IntoResponse,
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::SET_COOKIE,
|
header::SET_COOKIE,
|
||||||
auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure)
|
auth::make_oauth_state_cookie(&oauth_state, state.settings.session_cookie_secure)
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
|
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
|
||||||
);
|
);
|
||||||
|
|
@ -160,7 +158,10 @@ async fn auth_callback(
|
||||||
Query(query): Query<AuthCallbackQuery>,
|
Query(query): Query<AuthCallbackQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
auth::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.state)
|
auth::validate_oauth_state(
|
||||||
|
auth::read_cookie_from_headers(&headers, auth::OAUTH_STATE_COOKIE),
|
||||||
|
&query.state,
|
||||||
|
)
|
||||||
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
||||||
|
|
||||||
let token_res = state
|
let token_res = state
|
||||||
|
|
@ -227,69 +228,51 @@ async fn auth_callback(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||||
|
|
||||||
let (access_token, refresh_token) =
|
let session_id = auth::new_session_id();
|
||||||
auth::new_jwt_tokens(user.id, &state.settings.session_secret)
|
db::create_session(
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
user.id,
|
||||||
|
auth::session_expiry(),
|
||||||
|
auth::user_agent_hash(&headers),
|
||||||
|
auth::ip_hash(&headers),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create session: {e}")))?;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.append(
|
headers.append(
|
||||||
header::SET_COOKIE,
|
header::SET_COOKIE,
|
||||||
auth::clear_oauth_state_cookie(state.settings.cookie_secure)
|
auth::clear_oauth_state_cookie(state.settings.session_cookie_secure)
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
||||||
);
|
);
|
||||||
|
headers.append(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
auth::make_session_cookie(&session_id, state.settings.session_cookie_secure)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::internal("failed to set session cookie"))?,
|
||||||
|
);
|
||||||
|
|
||||||
Ok((
|
Ok((headers, Redirect::to("/")))
|
||||||
headers,
|
|
||||||
Redirect::to(&format!(
|
|
||||||
"/?token={}&refresh_token={}",
|
|
||||||
access_token, refresh_token
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
async fn auth_logout(
|
||||||
struct AuthRefreshBody {
|
|
||||||
refresh_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct AuthRefreshResponse {
|
|
||||||
access_token: String,
|
|
||||||
refresh_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn auth_refresh(
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<AuthRefreshBody>,
|
user: AuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let user_id = auth::verify_session(
|
db::revoke_session(&state.db, &user.session_id)
|
||||||
&body.refresh_token,
|
|
||||||
&state.settings.session_secret,
|
|
||||||
"refresh",
|
|
||||||
)
|
|
||||||
.map_err(|_| ApiError::unauthorized("invalid or expired refresh token"))?;
|
|
||||||
|
|
||||||
let exists = db::user_exists(&state.db, user_id)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::internal("user verification failed"))?;
|
.map_err(|e| ApiError::internal(&format!("failed to revoke session: {e}")))?;
|
||||||
|
|
||||||
if !exists {
|
let mut headers = HeaderMap::new();
|
||||||
return Err(ApiError::unauthorized("user not found"));
|
headers.insert(
|
||||||
}
|
header::SET_COOKIE,
|
||||||
|
auth::clear_session_cookie(state.settings.session_cookie_secure)
|
||||||
let (access_token, refresh_token) =
|
.parse()
|
||||||
auth::new_jwt_tokens(user_id, &state.settings.session_secret)
|
.map_err(|_| ApiError::internal("failed to clear session cookie"))?,
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
);
|
||||||
|
Ok((headers, StatusCode::NO_CONTENT))
|
||||||
Ok(Json(AuthRefreshResponse {
|
|
||||||
access_token,
|
|
||||||
refresh_token,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn auth_logout() -> Result<impl IntoResponse, ApiError> {
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -350,9 +333,12 @@ async fn me(State(state): State<AppState>, user: AuthUser) -> Result<impl IntoRe
|
||||||
|
|
||||||
async fn presence_list(
|
async fn presence_list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_user: AuthUser,
|
user: AuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let users = state.chat.get_online_users().await;
|
let visible_user_ids = db::list_visible_user_ids(&state.db, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to load visible users: {e}")))?;
|
||||||
|
let users = state.chat.get_online_users_for(&visible_user_ids).await;
|
||||||
Ok(Json(users))
|
Ok(Json(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,10 +770,10 @@ async fn upload_dm_attachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn voice_ws(
|
async fn voice_ws(
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
Path(channel_id): Path<Uuid>,
|
Path(channel_id): Path<Uuid>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
ensure_channel_member(&state, channel_id, user.id).await?;
|
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||||
|
|
||||||
|
|
@ -809,9 +795,9 @@ async fn voice_ws(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat_ws(
|
async fn chat_ws(
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
|
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
|
||||||
}
|
}
|
||||||
|
|
@ -875,25 +861,42 @@ async fn upload_sound(
|
||||||
_ => return Err(ApiError::bad_request("missing fields")),
|
_ => return Err(ApiError::bad_request("missing fields")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let extension = std::path::Path::new(&file_name)
|
let validated = validate_sound_upload(&file_name, &file_data)
|
||||||
.extension()
|
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("mp3");
|
|
||||||
|
|
||||||
let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension);
|
let storage = state
|
||||||
let upload_dir = std::path::Path::new("static/uploads/soundboard");
|
.media
|
||||||
tokio::fs::create_dir_all(upload_dir)
|
.as_ref()
|
||||||
|
.ok_or_else(|| ApiError::service_unavailable("media storage is not configured"))?;
|
||||||
|
|
||||||
|
let object_key = format!(
|
||||||
|
"soundboard/{guild_id}/{}-{}",
|
||||||
|
Uuid::new_v4(),
|
||||||
|
sanitize_file_name(&file_name)
|
||||||
|
);
|
||||||
|
let media_url = storage
|
||||||
|
.upload_object(
|
||||||
|
&object_key,
|
||||||
|
file_data.to_vec(),
|
||||||
|
&validated.mime_type,
|
||||||
|
&file_name,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
let file_path = upload_dir.join(&safe_file_name);
|
let sound = db::create_sound(
|
||||||
tokio::fs::write(&file_path, file_data)
|
&state.db,
|
||||||
.await
|
guild_id,
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
user.id,
|
||||||
|
name.trim(),
|
||||||
let web_path = format!("/static/uploads/soundboard/{}", safe_file_name);
|
icon.trim(),
|
||||||
|
&object_key,
|
||||||
let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?;
|
&media_url,
|
||||||
|
&validated.mime_type,
|
||||||
|
validated.size_bytes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Json(sound))
|
Ok(Json(sound))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -907,6 +910,13 @@ struct UploadedMedia {
|
||||||
original_filename: String,
|
original_filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ValidatedUpload {
|
||||||
|
mime_type: String,
|
||||||
|
inline: bool,
|
||||||
|
size_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
async fn upload_media_from_request(
|
async fn upload_media_from_request(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
|
@ -916,7 +926,7 @@ async fn upload_media_from_request(
|
||||||
let storage = state
|
let storage = state
|
||||||
.media
|
.media
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::internal("media storage is not configured"))?;
|
.ok_or_else(|| ApiError::service_unavailable("media storage is not configured"))?;
|
||||||
|
|
||||||
let original_filename = headers
|
let original_filename = headers
|
||||||
.get("x-file-name")
|
.get("x-file-name")
|
||||||
|
|
@ -926,7 +936,7 @@ async fn upload_media_from_request(
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.ok_or_else(|| ApiError::bad_request("missing x-file-name header"))?;
|
.ok_or_else(|| ApiError::bad_request("missing x-file-name header"))?;
|
||||||
|
|
||||||
let mime_type = headers
|
let raw_mime_type = headers
|
||||||
.get(header::CONTENT_TYPE)
|
.get(header::CONTENT_TYPE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|
@ -941,18 +951,26 @@ async fn upload_media_from_request(
|
||||||
return Err(ApiError::bad_request("file exceeds 50MB upload limit"));
|
return Err(ApiError::bad_request("file exceeds 50MB upload limit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let validated = validate_general_upload(&original_filename, &body, &raw_mime_type)
|
||||||
|
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
||||||
let safe_name = sanitize_file_name(&original_filename);
|
let safe_name = sanitize_file_name(&original_filename);
|
||||||
let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name);
|
let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name);
|
||||||
let size_bytes = body.len() as i64;
|
let size_bytes = body.len() as i64;
|
||||||
let media_url = storage
|
let media_url = storage
|
||||||
.upload_object(&object_key, body.to_vec(), &mime_type, &original_filename)
|
.upload_object(
|
||||||
|
&object_key,
|
||||||
|
body.to_vec(),
|
||||||
|
&validated.mime_type,
|
||||||
|
&original_filename,
|
||||||
|
validated.inline,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
Ok(UploadedMedia {
|
Ok(UploadedMedia {
|
||||||
object_key,
|
object_key,
|
||||||
media_url,
|
media_url,
|
||||||
mime_type,
|
mime_type: validated.mime_type,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
original_filename,
|
original_filename,
|
||||||
})
|
})
|
||||||
|
|
@ -1016,7 +1034,7 @@ fn sanitize_file_name(file_name: &str) -> String {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let trimmed = sanitized.trim_matches('_').trim();
|
let trimmed = sanitized.trim_matches(|ch| ch == '_' || ch == '.').trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
"upload.bin".to_string()
|
"upload.bin".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1024,6 +1042,141 @@ fn sanitize_file_name(file_name: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_general_upload(
|
||||||
|
file_name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
claimed_mime: &str,
|
||||||
|
) -> anyhow::Result<ValidatedUpload> {
|
||||||
|
let extension = lower_file_extension(file_name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file extension is required"))?;
|
||||||
|
let normalized_mime = sniff_upload_type(bytes, &extension, claimed_mime)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unsupported upload type"))?;
|
||||||
|
let inline = is_inline_media_type(&normalized_mime);
|
||||||
|
|
||||||
|
Ok(ValidatedUpload {
|
||||||
|
mime_type: normalized_mime,
|
||||||
|
inline,
|
||||||
|
size_bytes: bytes.len() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sound_upload(file_name: &str, bytes: &[u8]) -> anyhow::Result<ValidatedUpload> {
|
||||||
|
let extension = lower_file_extension(file_name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("sound file extension is required"))?;
|
||||||
|
let Some(mime_type) = sniff_audio_type(bytes, &extension) else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"only mp3, ogg, and wav sounds are supported"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ValidatedUpload {
|
||||||
|
mime_type,
|
||||||
|
inline: false,
|
||||||
|
size_bytes: bytes.len() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lower_file_extension(file_name: &str) -> Option<String> {
|
||||||
|
std::path::Path::new(file_name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_upload_type(bytes: &[u8], extension: &str, claimed_mime: &str) -> Option<String> {
|
||||||
|
sniff_audio_type(bytes, extension)
|
||||||
|
.or_else(|| sniff_image_type(bytes, extension))
|
||||||
|
.or_else(|| sniff_video_type(bytes, extension))
|
||||||
|
.or_else(|| sniff_document_type(extension, claimed_mime))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_audio_type(bytes: &[u8], extension: &str) -> Option<String> {
|
||||||
|
match extension {
|
||||||
|
"mp3" if bytes.starts_with(b"ID3") || bytes.first().copied() == Some(0xff) => {
|
||||||
|
Some("audio/mpeg".to_string())
|
||||||
|
}
|
||||||
|
"ogg" if bytes.starts_with(b"OggS") => Some("audio/ogg".to_string()),
|
||||||
|
"wav" if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") => {
|
||||||
|
Some("audio/wav".to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_image_type(bytes: &[u8], extension: &str) -> Option<String> {
|
||||||
|
match extension {
|
||||||
|
"png" if bytes.starts_with(b"\x89PNG\r\n\x1a\n") => Some("image/png".to_string()),
|
||||||
|
"jpg" | "jpeg" if bytes.starts_with(&[0xff, 0xd8, 0xff]) => Some("image/jpeg".to_string()),
|
||||||
|
"gif" if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") => {
|
||||||
|
Some("image/gif".to_string())
|
||||||
|
}
|
||||||
|
"webp" if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") => {
|
||||||
|
Some("image/webp".to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_video_type(bytes: &[u8], extension: &str) -> Option<String> {
|
||||||
|
match extension {
|
||||||
|
"mp4"
|
||||||
|
if bytes
|
||||||
|
.windows(8)
|
||||||
|
.any(|window| window == b"ftypisom" || window == b"ftypmp42") =>
|
||||||
|
{
|
||||||
|
Some("video/mp4".to_string())
|
||||||
|
}
|
||||||
|
"webm" if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) => Some("video/webm".to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_document_type(extension: &str, claimed_mime: &str) -> Option<String> {
|
||||||
|
match extension {
|
||||||
|
"pdf" => Some("application/pdf".to_string()),
|
||||||
|
"txt" => Some("text/plain".to_string()),
|
||||||
|
"json" => Some("application/json".to_string()),
|
||||||
|
"csv" => Some("text/csv".to_string()),
|
||||||
|
"md" => Some("text/markdown".to_string()),
|
||||||
|
"zip" => Some("application/zip".to_string()),
|
||||||
|
"gz" => Some("application/gzip".to_string()),
|
||||||
|
_ if matches!(
|
||||||
|
claimed_mime,
|
||||||
|
"application/pdf"
|
||||||
|
| "text/plain"
|
||||||
|
| "application/json"
|
||||||
|
| "text/csv"
|
||||||
|
| "text/markdown"
|
||||||
|
| "application/zip"
|
||||||
|
| "application/gzip"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Some(claimed_mime.to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_inline_media_type(mime_type: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
mime_type,
|
||||||
|
"image/png"
|
||||||
|
| "image/jpeg"
|
||||||
|
| "image/gif"
|
||||||
|
| "image/webp"
|
||||||
|
| "video/mp4"
|
||||||
|
| "video/webm"
|
||||||
|
| "audio/mpeg"
|
||||||
|
| "audio/ogg"
|
||||||
|
| "audio/wav"
|
||||||
|
| "application/pdf"
|
||||||
|
| "text/plain"
|
||||||
|
| "application/json"
|
||||||
|
| "text/csv"
|
||||||
|
| "text/markdown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn channel_object_key_prefix(channel_id: Uuid) -> String {
|
fn channel_object_key_prefix(channel_id: Uuid) -> String {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -1088,10 +1241,15 @@ async fn delete_sound(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file from disk
|
let object_key = db::get_sound_object_key(&state.db, sound_id)
|
||||||
let relative_path = sound.file_path.trim_start_matches('/');
|
.await
|
||||||
if let Err(e) = tokio::fs::remove_file(relative_path).await {
|
.map_err(|e| ApiError::internal(&format!("failed to load sound storage key: {e}")))?;
|
||||||
info!("failed to delete sound file {}: {}", relative_path, e);
|
|
||||||
|
if let (Some(storage), Some(object_key)) = (state.media.as_ref(), object_key.as_deref()) {
|
||||||
|
storage
|
||||||
|
.delete_object(object_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to delete sound media: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
db::delete_sound(&state.db, sound_id)
|
db::delete_sound(&state.db, sound_id)
|
||||||
|
|
@ -1135,3 +1293,100 @@ async fn ensure_channel_member(
|
||||||
|
|
||||||
ensure_guild_member(state, guild_id, user_id).await
|
ensure_guild_member(state, guild_id, user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
sanitize_file_name, upload_media_from_request, validate_general_upload,
|
||||||
|
validate_sound_upload,
|
||||||
|
};
|
||||||
|
use crate::{AppState, chat::ChatHub, config::Settings, voice::VoiceHub};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
};
|
||||||
|
use sea_orm::{DatabaseBackend, MockDatabase};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn test_state() -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: Arc::new(MockDatabase::new(DatabaseBackend::Postgres).into_connection()),
|
||||||
|
settings: Arc::new(Settings {
|
||||||
|
port: 3000,
|
||||||
|
app_base_url: "http://localhost:3000".to_string(),
|
||||||
|
app_origin: "http://localhost:3000".to_string(),
|
||||||
|
database_url: "postgres://localhost/test".to_string(),
|
||||||
|
oidc_client_id: "client".to_string(),
|
||||||
|
oidc_client_secret: "secret".to_string(),
|
||||||
|
oidc_authorize_url: "http://localhost:3000/oidc/authorize".to_string(),
|
||||||
|
oidc_token_url: "http://localhost:3000/oidc/token".to_string(),
|
||||||
|
oidc_userinfo_url: "http://localhost:3000/oidc/userinfo".to_string(),
|
||||||
|
oidc_redirect_url: "http://localhost:3000/auth/callback".to_string(),
|
||||||
|
oidc_scopes: "openid profile email".to_string(),
|
||||||
|
session_cookie_secure: false,
|
||||||
|
stun_urls: vec!["stun:stun.l.google.com:19302".to_string()],
|
||||||
|
turn_urls: Vec::new(),
|
||||||
|
turn_username: None,
|
||||||
|
turn_password: None,
|
||||||
|
media: None,
|
||||||
|
}),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
voice: Arc::new(VoiceHub::default()),
|
||||||
|
chat: Arc::new(ChatHub::default()),
|
||||||
|
media: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_file_name_replaces_unsafe_chars() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_file_name("../../hello world?.mp3"),
|
||||||
|
"hello_world_.mp3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_png_uploads() {
|
||||||
|
let png = b"\x89PNG\r\n\x1a\nrest";
|
||||||
|
let upload = validate_general_upload("image.png", png, "image/png").unwrap();
|
||||||
|
assert_eq!(upload.mime_type, "image/png");
|
||||||
|
assert!(upload.inline);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_uploads() {
|
||||||
|
let err = validate_general_upload("payload.exe", b"MZ...", "application/octet-stream")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unsupported upload type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_sound_uploads() {
|
||||||
|
let wav = b"RIFFdataWAVE";
|
||||||
|
let upload = validate_sound_upload("sound.wav", wav).unwrap();
|
||||||
|
assert_eq!(upload.mime_type, "audio/wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_helper_requires_configured_media_storage() {
|
||||||
|
let state = test_state();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-file-name", "photo.png".parse().unwrap());
|
||||||
|
headers.insert(header::CONTENT_TYPE, "image/png".parse().unwrap());
|
||||||
|
|
||||||
|
let err = match upload_media_from_request(
|
||||||
|
&state,
|
||||||
|
&headers,
|
||||||
|
Bytes::from_static(b"\x89PNG\r\n\x1a\nrest"),
|
||||||
|
"channels/test".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("expected upload helper to reject missing media storage"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert_eq!(err.message, "media storage is not configured");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1116
src/main.rs
1116
src/main.rs
File diff suppressed because it is too large
Load diff
15
src/media.rs
15
src/media.rs
|
|
@ -47,6 +47,7 @@ impl MediaStorage {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
original_filename: &str,
|
original_filename: &str,
|
||||||
|
inline: bool,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
self.client
|
self.client
|
||||||
.put_object()
|
.put_object()
|
||||||
|
|
@ -55,7 +56,8 @@ impl MediaStorage {
|
||||||
.body(ByteStream::from(bytes))
|
.body(ByteStream::from(bytes))
|
||||||
.content_type(content_type)
|
.content_type(content_type)
|
||||||
.content_disposition(format!(
|
.content_disposition(format!(
|
||||||
"inline; filename=\"{}\"",
|
"{}; filename=\"{}\"",
|
||||||
|
if inline { "inline" } else { "attachment" },
|
||||||
sanitize_header_value(original_filename)
|
sanitize_header_value(original_filename)
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -64,6 +66,17 @@ impl MediaStorage {
|
||||||
|
|
||||||
Ok(format!("{}/{}", self.public_base_url, object_key))
|
Ok(format!("{}/{}", self.public_base_url, object_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_object(&self, object_key: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("failed to delete object from R2: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_header_value(value: &str) -> String {
|
fn sanitize_header_value(value: &str) -> String {
|
||||||
|
|
|
||||||
107
src/migration/m20260227_000007_sessions.rs
Normal file
107
src/migration/m20260227_000007_sessions.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Sessions::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Sessions::Id)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Sessions::UserId).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Sessions::ExpiresAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Sessions::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Sessions::LastSeenAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Sessions::RevokedAt).timestamp_with_time_zone())
|
||||||
|
.col(ColumnDef::new(Sessions::UserAgentHash).string())
|
||||||
|
.col(ColumnDef::new(Sessions::IpHash).string())
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_sessions_user")
|
||||||
|
.from(Sessions::Table, Sessions::UserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_sessions_user_id")
|
||||||
|
.table(Sessions::Table)
|
||||||
|
.col(Sessions::UserId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_sessions_expires_at")
|
||||||
|
.table(Sessions::Table)
|
||||||
|
.col(Sessions::ExpiresAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_sessions_revoked_at")
|
||||||
|
.table(Sessions::Table)
|
||||||
|
.col(Sessions::RevokedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Sessions::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Sessions {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
UserId,
|
||||||
|
ExpiresAt,
|
||||||
|
CreatedAt,
|
||||||
|
LastSeenAt,
|
||||||
|
RevokedAt,
|
||||||
|
UserAgentHash,
|
||||||
|
IpHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
72
src/migration/m20260227_000008_soundboard_media.rs
Normal file
72
src/migration/m20260227_000008_soundboard_media.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SoundboardSounds::Table)
|
||||||
|
.add_column(ColumnDef::new(SoundboardSounds::ObjectKey).string().null())
|
||||||
|
.add_column(ColumnDef::new(SoundboardSounds::MediaUrl).string().null())
|
||||||
|
.add_column(ColumnDef::new(SoundboardSounds::MimeType).string().null())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(SoundboardSounds::SizeBytes)
|
||||||
|
.big_integer()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
UPDATE soundboard_sounds
|
||||||
|
SET media_url = file_path
|
||||||
|
WHERE media_url IS NULL
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SoundboardSounds::Table)
|
||||||
|
.modify_column(
|
||||||
|
ColumnDef::new(SoundboardSounds::MediaUrl)
|
||||||
|
.string()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SoundboardSounds::Table)
|
||||||
|
.drop_column(SoundboardSounds::ObjectKey)
|
||||||
|
.drop_column(SoundboardSounds::MediaUrl)
|
||||||
|
.drop_column(SoundboardSounds::MimeType)
|
||||||
|
.drop_column(SoundboardSounds::SizeBytes)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum SoundboardSounds {
|
||||||
|
Table,
|
||||||
|
ObjectKey,
|
||||||
|
MediaUrl,
|
||||||
|
MimeType,
|
||||||
|
SizeBytes,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SoundboardSounds::Table)
|
||||||
|
.modify_column(ColumnDef::new(SoundboardSounds::FilePath).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
UPDATE soundboard_sounds
|
||||||
|
SET file_path = media_url
|
||||||
|
WHERE file_path IS NULL
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SoundboardSounds::Table)
|
||||||
|
.modify_column(
|
||||||
|
ColumnDef::new(SoundboardSounds::FilePath)
|
||||||
|
.string()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum SoundboardSounds {
|
||||||
|
Table,
|
||||||
|
FilePath,
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,9 @@ mod m20260213_000003_channel_kind;
|
||||||
mod m20260213_000004_direct_messages;
|
mod m20260213_000004_direct_messages;
|
||||||
mod m20260224_000005_soundboard;
|
mod m20260224_000005_soundboard;
|
||||||
mod m20260227_000006_attachments;
|
mod m20260227_000006_attachments;
|
||||||
|
mod m20260227_000007_sessions;
|
||||||
|
mod m20260227_000008_soundboard_media;
|
||||||
|
mod m20260227_000009_soundboard_file_path_nullable;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
|
|
@ -19,6 +22,9 @@ impl MigratorTrait for Migrator {
|
||||||
Box::new(m20260213_000004_direct_messages::Migration),
|
Box::new(m20260213_000004_direct_messages::Migration),
|
||||||
Box::new(m20260224_000005_soundboard::Migration),
|
Box::new(m20260224_000005_soundboard::Migration),
|
||||||
Box::new(m20260227_000006_attachments::Migration),
|
Box::new(m20260227_000006_attachments::Migration),
|
||||||
|
Box::new(m20260227_000007_sessions::Migration),
|
||||||
|
Box::new(m20260227_000008_soundboard_media::Migration),
|
||||||
|
Box::new(m20260227_000009_soundboard_file_path_nullable::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,6 @@ pub struct Channel {
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct Message {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub author_user_id: Uuid,
|
|
||||||
pub body: String,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct Attachment {
|
pub struct Attachment {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
@ -102,7 +93,9 @@ pub struct SoundboardSound {
|
||||||
pub guild_id: Uuid,
|
pub guild_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub file_path: String,
|
pub media_url: String,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub size_bytes: Option<i64>,
|
||||||
pub created_by_user_id: Uuid,
|
pub created_by_user_id: Uuid,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
486
src/voice.rs
486
src/voice.rs
|
|
@ -10,7 +10,7 @@ use crate::{AppState, db};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct VoiceHub {
|
pub struct VoiceHub {
|
||||||
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
|
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, HashMap<Uuid, ClientHandle>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -72,7 +72,7 @@ enum ServerEvent {
|
||||||
},
|
},
|
||||||
PlaySound {
|
PlaySound {
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
sound_url: String,
|
media_url: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,10 +97,20 @@ enum ClientEvent {
|
||||||
is_muted: bool,
|
is_muted: bool,
|
||||||
},
|
},
|
||||||
PlaySound {
|
PlaySound {
|
||||||
sound_url: String,
|
sound_id: Uuid,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct VoiceStateChange {
|
||||||
|
joined: Option<VoiceParticipant>,
|
||||||
|
left_user_id: Option<Uuid>,
|
||||||
|
video_changed: Option<(Uuid, bool)>,
|
||||||
|
screen_changed: Option<(Uuid, bool)>,
|
||||||
|
speaking_changed: Option<(Uuid, bool)>,
|
||||||
|
mute_changed: Option<(Uuid, bool)>,
|
||||||
|
}
|
||||||
|
|
||||||
impl VoiceHub {
|
impl VoiceHub {
|
||||||
pub async fn participants(&self, room_id: Uuid) -> Vec<VoiceParticipant> {
|
pub async fn participants(&self, room_id: Uuid) -> Vec<VoiceParticipant> {
|
||||||
let rooms = self.rooms.read().await;
|
let rooms = self.rooms.read().await;
|
||||||
|
|
@ -109,14 +119,7 @@ impl VoiceHub {
|
||||||
};
|
};
|
||||||
|
|
||||||
room.iter()
|
room.iter()
|
||||||
.map(|(user_id, handle)| VoiceParticipant {
|
.filter_map(|(user_id, connections)| aggregate_participant(Some(connections), *user_id))
|
||||||
user_id: *user_id,
|
|
||||||
display_name: handle.display_name.clone(),
|
|
||||||
is_sharing_video: handle.is_sharing_video,
|
|
||||||
is_sharing_screen: handle.is_sharing_screen,
|
|
||||||
is_speaking: handle.is_speaking,
|
|
||||||
is_muted: handle.is_muted,
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,28 +127,23 @@ impl VoiceHub {
|
||||||
&self,
|
&self,
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
) -> Vec<VoiceParticipant> {
|
) -> (Vec<VoiceParticipant>, VoiceStateChange) {
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let room = rooms.entry(room_id).or_default();
|
let room = rooms.entry(room_id).or_default();
|
||||||
|
|
||||||
let peers = room
|
let peers = room
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(peer_id, peer)| VoiceParticipant {
|
.filter_map(|(peer_id, connections)| aggregate_participant(Some(connections), *peer_id))
|
||||||
user_id: *peer_id,
|
|
||||||
display_name: peer.display_name.clone(),
|
|
||||||
is_sharing_video: peer.is_sharing_video,
|
|
||||||
is_sharing_screen: peer.is_sharing_screen,
|
|
||||||
is_speaking: peer.is_speaking,
|
|
||||||
is_muted: peer.is_muted,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
room.insert(
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
user_id,
|
room.entry(user_id).or_default().insert(
|
||||||
|
connection_id,
|
||||||
ClientHandle {
|
ClientHandle {
|
||||||
display_name: display_name.clone(),
|
display_name,
|
||||||
is_sharing_video: false,
|
is_sharing_video: false,
|
||||||
is_sharing_screen: false,
|
is_sharing_screen: false,
|
||||||
is_speaking: false,
|
is_speaking: false,
|
||||||
|
|
@ -153,34 +151,31 @@ impl VoiceHub {
|
||||||
tx,
|
tx,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
(peers, diff_voice_state(previous, current))
|
||||||
if *peer_id != user_id {
|
|
||||||
let _ = peer.tx.send(ServerEvent::PeerJoined {
|
|
||||||
user_id,
|
|
||||||
display_name: display_name.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
peers
|
async fn leave(&self, room_id: Uuid, user_id: Uuid, connection_id: Uuid) -> VoiceStateChange {
|
||||||
}
|
|
||||||
|
|
||||||
async fn leave(&self, room_id: Uuid, user_id: Uuid) {
|
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let Some(room) = rooms.get_mut(&room_id) else {
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
return;
|
return VoiceStateChange::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
if let Some(connections) = room.get_mut(&user_id) {
|
||||||
|
connections.remove(&connection_id);
|
||||||
|
if connections.is_empty() {
|
||||||
room.remove(&user_id);
|
room.remove(&user_id);
|
||||||
|
|
||||||
for peer in room.values() {
|
|
||||||
let _ = peer.tx.send(ServerEvent::PeerLeft { user_id });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
|
||||||
if room.is_empty() {
|
if room.is_empty() {
|
||||||
rooms.remove(&room_id);
|
rooms.remove(&room_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diff_voice_state(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn relay_signal(
|
pub async fn relay_signal(
|
||||||
|
|
@ -196,7 +191,10 @@ impl VoiceHub {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(target) = room.get(&to_user_id) {
|
if let Some(target) = room
|
||||||
|
.get(&to_user_id)
|
||||||
|
.and_then(|connections| connections.values().next())
|
||||||
|
{
|
||||||
let _ = target.tx.send(ServerEvent::Signal {
|
let _ = target.tx.send(ServerEvent::Signal {
|
||||||
from_user_id,
|
from_user_id,
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -205,116 +203,213 @@ impl VoiceHub {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_video_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_video: bool) {
|
async fn set_video_status(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
is_sharing_video: bool,
|
||||||
|
) -> VoiceStateChange {
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let Some(room) = rooms.get_mut(&room_id) else {
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
return;
|
return VoiceStateChange::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(handle) = room.get_mut(&user_id) {
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
if let Some(handle) = room
|
||||||
|
.get_mut(&user_id)
|
||||||
|
.and_then(|connections| connections.get_mut(&connection_id))
|
||||||
|
{
|
||||||
handle.is_sharing_video = is_sharing_video;
|
handle.is_sharing_video = is_sharing_video;
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
|
||||||
if *peer_id != user_id {
|
|
||||||
let _ = peer.tx.send(ServerEvent::VideoStatusChanged {
|
|
||||||
user_id,
|
|
||||||
is_sharing_video,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
diff_voice_state(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_screen_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_screen: bool) {
|
async fn set_screen_status(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
is_sharing_screen: bool,
|
||||||
|
) -> VoiceStateChange {
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let Some(room) = rooms.get_mut(&room_id) else {
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
return;
|
return VoiceStateChange::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(handle) = room.get_mut(&user_id) {
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
if let Some(handle) = room
|
||||||
|
.get_mut(&user_id)
|
||||||
|
.and_then(|connections| connections.get_mut(&connection_id))
|
||||||
|
{
|
||||||
handle.is_sharing_screen = is_sharing_screen;
|
handle.is_sharing_screen = is_sharing_screen;
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
|
||||||
if *peer_id != user_id {
|
|
||||||
let _ = peer.tx.send(ServerEvent::ScreenStatusChanged {
|
|
||||||
user_id,
|
|
||||||
is_sharing_screen,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
diff_voice_state(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_speaking_status(&self, room_id: Uuid, user_id: Uuid, is_speaking: bool) {
|
async fn set_speaking_status(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
is_speaking: bool,
|
||||||
|
) -> VoiceStateChange {
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let Some(room) = rooms.get_mut(&room_id) else {
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
return;
|
return VoiceStateChange::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(handle) = room.get_mut(&user_id) {
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
if let Some(handle) = room
|
||||||
|
.get_mut(&user_id)
|
||||||
|
.and_then(|connections| connections.get_mut(&connection_id))
|
||||||
|
{
|
||||||
handle.is_speaking = is_speaking;
|
handle.is_speaking = is_speaking;
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
|
||||||
if *peer_id != user_id {
|
|
||||||
let _ = peer.tx.send(ServerEvent::SpeakingStatusChanged {
|
|
||||||
user_id,
|
|
||||||
is_speaking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
diff_voice_state(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_mute_status(&self, room_id: Uuid, user_id: Uuid, is_muted: bool) {
|
async fn set_mute_status(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
is_muted: bool,
|
||||||
|
) -> VoiceStateChange {
|
||||||
let mut rooms = self.rooms.write().await;
|
let mut rooms = self.rooms.write().await;
|
||||||
let Some(room) = rooms.get_mut(&room_id) else {
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
return;
|
return VoiceStateChange::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(handle) = room.get_mut(&user_id) {
|
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
if let Some(handle) = room
|
||||||
|
.get_mut(&user_id)
|
||||||
|
.and_then(|connections| connections.get_mut(&connection_id))
|
||||||
|
{
|
||||||
handle.is_muted = is_muted;
|
handle.is_muted = is_muted;
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
|
||||||
if *peer_id != user_id {
|
|
||||||
let _ = peer
|
|
||||||
.tx
|
|
||||||
.send(ServerEvent::MuteStatusChanged { user_id, is_muted });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||||
|
diff_voice_state(previous, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) {
|
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, media_url: String) {
|
||||||
let rooms = self.rooms.read().await;
|
let rooms = self.rooms.read().await;
|
||||||
let Some(room) = rooms.get(&room_id) else {
|
let Some(room) = rooms.get(&room_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (peer_id, peer) in room.iter() {
|
for (peer_id, connections) in room.iter() {
|
||||||
if *peer_id == user_id {
|
if *peer_id == user_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
for peer in connections.values() {
|
||||||
let _ = peer.tx.send(ServerEvent::PlaySound {
|
let _ = peer.tx.send(ServerEvent::PlaySound {
|
||||||
user_id,
|
user_id,
|
||||||
sound_url: sound_url.clone(),
|
media_url: media_url.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn emit_change(&self, room_id: Uuid, user_id: Uuid, change: VoiceStateChange) {
|
||||||
|
if is_voice_state_change_empty(&change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rooms = self.rooms.read().await;
|
||||||
|
let Some(room) = rooms.get(&room_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(participant) = change.joined {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::PeerJoined {
|
||||||
|
user_id: participant.user_id,
|
||||||
|
display_name: participant.display_name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(left_user_id) = change.left_user_id {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::PeerLeft {
|
||||||
|
user_id: left_user_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((changed_user_id, is_sharing_video)) = change.video_changed {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::VideoStatusChanged {
|
||||||
|
user_id: changed_user_id,
|
||||||
|
is_sharing_video,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((changed_user_id, is_sharing_screen)) = change.screen_changed {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::ScreenStatusChanged {
|
||||||
|
user_id: changed_user_id,
|
||||||
|
is_sharing_screen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((changed_user_id, is_speaking)) = change.speaking_changed {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::SpeakingStatusChanged {
|
||||||
|
user_id: changed_user_id,
|
||||||
|
is_speaking,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((changed_user_id, is_muted)) = change.mute_changed {
|
||||||
|
broadcast_voice_event(
|
||||||
|
room,
|
||||||
|
user_id,
|
||||||
|
ServerEvent::MuteStatusChanged {
|
||||||
|
user_id: changed_user_id,
|
||||||
|
is_muted,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
|
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
|
||||||
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
|
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let Ok(Some(guild_id)) = db::guild_id_for_channel(&state.db, room_id).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
|
||||||
let peers = state
|
let (peers, change) = state
|
||||||
.voice
|
.voice
|
||||||
.join(room_id, user_id, user.display_name.clone(), tx.clone())
|
.join(
|
||||||
|
room_id,
|
||||||
|
user_id,
|
||||||
|
connection_id,
|
||||||
|
user.display_name.clone(),
|
||||||
|
tx.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = tx.send(ServerEvent::Peers { peers });
|
let _ = tx.send(ServerEvent::Peers { peers });
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
|
|
||||||
let send_task = tokio::spawn(async move {
|
let send_task = tokio::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
|
|
@ -343,31 +438,42 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
|
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
|
||||||
state
|
let change = state
|
||||||
.voice
|
.voice
|
||||||
.set_video_status(room_id, user_id, is_sharing_video)
|
.set_video_status(room_id, user_id, connection_id, is_sharing_video)
|
||||||
.await;
|
.await;
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
}
|
}
|
||||||
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
|
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
|
||||||
state
|
let change = state
|
||||||
.voice
|
.voice
|
||||||
.set_screen_status(room_id, user_id, is_sharing_screen)
|
.set_screen_status(room_id, user_id, connection_id, is_sharing_screen)
|
||||||
.await;
|
.await;
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
}
|
}
|
||||||
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
|
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
|
||||||
state
|
let change = state
|
||||||
.voice
|
.voice
|
||||||
.set_speaking_status(room_id, user_id, is_speaking)
|
.set_speaking_status(room_id, user_id, connection_id, is_speaking)
|
||||||
.await;
|
.await;
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
}
|
}
|
||||||
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
|
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
|
||||||
|
let change = state
|
||||||
|
.voice
|
||||||
|
.set_mute_status(room_id, user_id, connection_id, is_muted)
|
||||||
|
.await;
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
|
}
|
||||||
|
Ok(ClientEvent::PlaySound { sound_id }) => {
|
||||||
|
if let Ok(Some(sound)) = db::get_sound_by_id(&state.db, sound_id).await
|
||||||
|
&& sound.guild_id == guild_id
|
||||||
|
{
|
||||||
state
|
state
|
||||||
.voice
|
.voice
|
||||||
.set_mute_status(room_id, user_id, is_muted)
|
.play_sound(room_id, user_id, sound.media_url)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Ok(ClientEvent::PlaySound { sound_url }) => {
|
|
||||||
state.voice.play_sound(room_id, user_id, sound_url).await;
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = tx.send(ServerEvent::Error {
|
let _ = tx.send(ServerEvent::Error {
|
||||||
|
|
@ -382,5 +488,183 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
|
||||||
}
|
}
|
||||||
|
|
||||||
send_task.abort();
|
send_task.abort();
|
||||||
state.voice.leave(room_id, user_id).await;
|
let change = state.voice.leave(room_id, user_id, connection_id).await;
|
||||||
|
state.voice.emit_change(room_id, user_id, change).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_participant(
|
||||||
|
connections: Option<&HashMap<Uuid, ClientHandle>>,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Option<VoiceParticipant> {
|
||||||
|
let connections = connections?;
|
||||||
|
if connections.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut handles = connections.values();
|
||||||
|
let first = handles.next()?;
|
||||||
|
Some(VoiceParticipant {
|
||||||
|
user_id,
|
||||||
|
display_name: first.display_name.clone(),
|
||||||
|
is_sharing_video: connections.values().any(|handle| handle.is_sharing_video),
|
||||||
|
is_sharing_screen: connections.values().any(|handle| handle.is_sharing_screen),
|
||||||
|
is_speaking: connections.values().any(|handle| handle.is_speaking),
|
||||||
|
is_muted: connections.values().all(|handle| handle.is_muted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_voice_state(
|
||||||
|
previous: Option<VoiceParticipant>,
|
||||||
|
current: Option<VoiceParticipant>,
|
||||||
|
) -> VoiceStateChange {
|
||||||
|
match (previous, current) {
|
||||||
|
(None, None) => VoiceStateChange::default(),
|
||||||
|
(None, Some(current)) => VoiceStateChange {
|
||||||
|
joined: Some(current),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
(Some(previous), None) => VoiceStateChange {
|
||||||
|
left_user_id: Some(previous.user_id),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
(Some(previous), Some(current)) => VoiceStateChange {
|
||||||
|
video_changed: (previous.is_sharing_video != current.is_sharing_video)
|
||||||
|
.then_some((current.user_id, current.is_sharing_video)),
|
||||||
|
screen_changed: (previous.is_sharing_screen != current.is_sharing_screen)
|
||||||
|
.then_some((current.user_id, current.is_sharing_screen)),
|
||||||
|
speaking_changed: (previous.is_speaking != current.is_speaking)
|
||||||
|
.then_some((current.user_id, current.is_speaking)),
|
||||||
|
mute_changed: (previous.is_muted != current.is_muted)
|
||||||
|
.then_some((current.user_id, current.is_muted)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_voice_event(
|
||||||
|
room: &HashMap<Uuid, HashMap<Uuid, ClientHandle>>,
|
||||||
|
source_user_id: Uuid,
|
||||||
|
event: ServerEvent,
|
||||||
|
) {
|
||||||
|
for (peer_id, connections) in room {
|
||||||
|
if *peer_id == source_user_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for peer in connections.values() {
|
||||||
|
let _ = peer.tx.send(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_voice_state_change_empty(change: &VoiceStateChange) -> bool {
|
||||||
|
change.joined.is_none()
|
||||||
|
&& change.left_user_id.is_none()
|
||||||
|
&& change.video_changed.is_none()
|
||||||
|
&& change.screen_changed.is_none()
|
||||||
|
&& change.speaking_changed.is_none()
|
||||||
|
&& change.mute_changed.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{VoiceHub, aggregate_participant};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_connections_keep_voice_participant_until_last_leave() {
|
||||||
|
let hub = VoiceHub::default();
|
||||||
|
let room_id = Uuid::new_v4();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let (_peers, first_join) = hub
|
||||||
|
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
|
||||||
|
.await;
|
||||||
|
let (_peers, second_join) = hub
|
||||||
|
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
|
||||||
|
.await;
|
||||||
|
let first_leave = hub.leave(room_id, user_id, first_connection).await;
|
||||||
|
let second_leave = hub.leave(room_id, user_id, second_connection).await;
|
||||||
|
|
||||||
|
assert!(first_join.joined.is_some());
|
||||||
|
assert!(second_join.joined.is_none());
|
||||||
|
assert!(first_leave.left_user_id.is_none());
|
||||||
|
assert_eq!(second_leave.left_user_id, Some(user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn voice_mute_state_only_flips_when_all_connections_are_muted() {
|
||||||
|
let hub = VoiceHub::default();
|
||||||
|
let room_id = Uuid::new_v4();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let _ = hub
|
||||||
|
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
|
||||||
|
.await;
|
||||||
|
let _ = hub
|
||||||
|
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let first_mute = hub
|
||||||
|
.set_mute_status(room_id, user_id, first_connection, true)
|
||||||
|
.await;
|
||||||
|
let second_mute = hub
|
||||||
|
.set_mute_status(room_id, user_id, second_connection, true)
|
||||||
|
.await;
|
||||||
|
let unmute = hub
|
||||||
|
.set_mute_status(room_id, user_id, first_connection, false)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(first_mute.mute_changed.is_none());
|
||||||
|
assert_eq!(second_mute.mute_changed, Some((user_id, true)));
|
||||||
|
assert_eq!(unmute.mute_changed, Some((user_id, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_participant_combines_connection_state() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let first_connection = Uuid::new_v4();
|
||||||
|
let second_connection = Uuid::new_v4();
|
||||||
|
let mut connections = HashMap::new();
|
||||||
|
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||||
|
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
connections.insert(
|
||||||
|
first_connection,
|
||||||
|
super::ClientHandle {
|
||||||
|
display_name: "User".to_string(),
|
||||||
|
is_sharing_video: true,
|
||||||
|
is_sharing_screen: false,
|
||||||
|
is_speaking: false,
|
||||||
|
is_muted: true,
|
||||||
|
tx: tx1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
connections.insert(
|
||||||
|
second_connection,
|
||||||
|
super::ClientHandle {
|
||||||
|
display_name: "User".to_string(),
|
||||||
|
is_sharing_video: false,
|
||||||
|
is_sharing_screen: true,
|
||||||
|
is_speaking: true,
|
||||||
|
is_muted: false,
|
||||||
|
tx: tx2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let participant = aggregate_participant(Some(&connections), user_id).unwrap();
|
||||||
|
assert!(participant.is_sharing_video);
|
||||||
|
assert!(participant.is_sharing_screen);
|
||||||
|
assert!(participant.is_speaking);
|
||||||
|
assert!(!participant.is_muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Chattz</title>
|
<title>Chattz</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<!-- Lucide Icons -->
|
<!-- Lucide Icons -->
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="/static/vendor/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -120,6 +117,10 @@
|
||||||
<div class="user-status">Online</div>
|
<div class="user-status">Online</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-actions">
|
<div class="user-actions">
|
||||||
|
<div id="update-notifier" class="update-notifier hidden">
|
||||||
|
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
||||||
|
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
||||||
|
</div>
|
||||||
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||||
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const state = {
|
const state = {
|
||||||
|
sessionActive: false,
|
||||||
me: null,
|
me: null,
|
||||||
guilds: [],
|
guilds: [],
|
||||||
channels: [],
|
channels: [],
|
||||||
|
|
@ -28,6 +29,7 @@ const state = {
|
||||||
viewMode: 'chat', // 'chat' or 'video'
|
viewMode: 'chat', // 'chat' or 'video'
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
peerGainNodes: new Map(), // userId -> GainNode
|
peerGainNodes: new Map(), // userId -> GainNode
|
||||||
|
peerAudioNodes: new Map(), // userId -> { sourceNode, compressorNode, gainNode }
|
||||||
visibleVolumeSliders: new Set(), // userIds whose sliders are visible
|
visibleVolumeSliders: new Set(), // userIds whose sliders are visible
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
|
|
@ -38,15 +40,8 @@ const state = {
|
||||||
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
|
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Desktop Backend Configuration ---
|
// --- Storage ---
|
||||||
let API_BASE_URL = ''; // Will be initialized via IPC
|
|
||||||
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
||||||
const PERSISTED_STORAGE_KEYS = [
|
|
||||||
"chattz_token",
|
|
||||||
"chattz_refresh_token",
|
|
||||||
LAST_GUILD_STORAGE_KEY,
|
|
||||||
"active_voice_channel",
|
|
||||||
];
|
|
||||||
|
|
||||||
function storageGet(key) {
|
function storageGet(key) {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
|
|
@ -54,103 +49,15 @@ function storageGet(key) {
|
||||||
|
|
||||||
function storageSet(key, value) {
|
function storageSet(key, value) {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
if (window.electronAPI?.storageSet) {
|
|
||||||
void window.electronAPI.storageSet(key, value).catch((err) => {
|
|
||||||
console.warn("Failed to persist storage key", key, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageRemove(key) {
|
function storageRemove(key) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
if (window.electronAPI?.storageRemove) {
|
|
||||||
void window.electronAPI.storageRemove(key).catch((err) => {
|
|
||||||
console.warn("Failed to remove persisted storage key", key, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storageSetCritical(key, value) {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
if (window.electronAPI?.storageSet) {
|
|
||||||
try {
|
|
||||||
await window.electronAPI.storageSet(key, value);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to persist critical storage key", key, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storageRemoveCritical(key) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
if (window.electronAPI?.storageRemove) {
|
|
||||||
try {
|
|
||||||
await window.electronAPI.storageRemove(key);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to remove critical storage key", key, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateDesktopStorage() {
|
|
||||||
if (!window.electronAPI?.storageGet) return;
|
|
||||||
for (const key of PERSISTED_STORAGE_KEYS) {
|
|
||||||
try {
|
|
||||||
// IPC file store (renderer-storage.json) is the source of truth —
|
|
||||||
// it always survives restarts, unlike localStorage on file:// URLs.
|
|
||||||
const val = await window.electronAPI.storageGet(key);
|
|
||||||
if (typeof val === "string") {
|
|
||||||
localStorage.setItem(key, val);
|
|
||||||
} else {
|
|
||||||
// IPC store is empty; backfill from localStorage if available
|
|
||||||
const localVal = localStorage.getItem(key);
|
|
||||||
if (localVal !== null) {
|
|
||||||
await window.electronAPI.storageSet(key, localVal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to hydrate storage key", key, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeConfig() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI?.getConfig) {
|
|
||||||
const config = await window.electronAPI.getConfig();
|
|
||||||
API_BASE_URL = config.backendUrl;
|
|
||||||
console.log(`Backend initialized: ${API_BASE_URL}`);
|
|
||||||
} else {
|
|
||||||
API_BASE_URL = '';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch config via IPC", err);
|
|
||||||
// Fallback to URL search params if IPC fails (unlikely in desktop app)
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
API_BASE_URL = urlParams.get('backend') || '';
|
|
||||||
} finally {
|
|
||||||
// Start the app once config resolution has completed.
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(path) {
|
function getWsUrl(path) {
|
||||||
let urlStr;
|
|
||||||
if (!API_BASE_URL) {
|
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
urlStr = `${proto}://${location.host}${path}`;
|
return `${proto}://${location.host}${path}`;
|
||||||
} else {
|
|
||||||
const url = new URL(API_BASE_URL);
|
|
||||||
const proto = url.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
||||||
urlStr = `${proto}://${url.host}${normalizedPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = storageGet("chattz_token");
|
|
||||||
if (token) {
|
|
||||||
urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`;
|
|
||||||
}
|
|
||||||
return urlStr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -248,57 +155,83 @@ const el = {
|
||||||
gifResults: document.getElementById("gif-results"),
|
gifResults: document.getElementById("gif-results"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function stopVoicePresencePolling() {
|
||||||
|
if (!state.voicePresencePollId) return;
|
||||||
|
clearInterval(state.voicePresencePollId);
|
||||||
|
state.voicePresencePollId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChatWs() {
|
||||||
|
if (!state.chatWs) return;
|
||||||
|
const ws = state.chatWs;
|
||||||
|
state.chatWs = null;
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.onmessage = null;
|
||||||
|
ws.onerror = null;
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLoggedOutState(message = "Session expired, please log in again") {
|
||||||
|
state.sessionActive = false;
|
||||||
|
stopVoicePresencePolling();
|
||||||
|
closeChatWs();
|
||||||
|
await leaveVoice();
|
||||||
|
|
||||||
|
state.me = null;
|
||||||
|
state.guilds = [];
|
||||||
|
state.channels = [];
|
||||||
|
state.dmConversations = [];
|
||||||
|
state.members = [];
|
||||||
|
state.voicePresence.clear();
|
||||||
|
state.selectedGuildId = null;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
state.lastMessageId = null;
|
||||||
|
state.onlineUsers.clear();
|
||||||
|
state.idleUsers.clear();
|
||||||
|
state.userVolumes.clear();
|
||||||
|
state.voice.viewMode = 'chat';
|
||||||
|
|
||||||
|
el.userName.textContent = "Username";
|
||||||
|
el.userAvatar.textContent = "U";
|
||||||
|
el.status.textContent = message;
|
||||||
|
el.soundboard.classList.add("hidden");
|
||||||
|
el.voiceConnection.classList.add("hidden");
|
||||||
|
renderGuilds();
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
renderMembers();
|
||||||
|
renderMessages([]);
|
||||||
|
updateHeaderLabels();
|
||||||
|
el.main.classList.add("hidden");
|
||||||
|
el.authScreen.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
// --- API Helpers ---
|
// --- API Helpers ---
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const fullUrl = API_BASE_URL ? (path.startsWith('http') ? path : `${API_BASE_URL}${path}`) : path;
|
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
"content-type": "application/json",
|
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
|
if (!headers["content-type"] && options.body && !(options.body instanceof FormData)) {
|
||||||
const token = storageGet("chattz_token");
|
headers["content-type"] = "application/json";
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await fetch(fullUrl, {
|
const res = await fetch(path, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle 401 Unauthorized via Refresh Token
|
if (res.status === 401) {
|
||||||
if (res.status === 401 && !path.includes('/auth/refresh')) {
|
await showLoggedOutState("Session expired, please log in again");
|
||||||
const refreshToken = storageGet("chattz_refresh_token");
|
const err = new Error("Session expired, please log in again");
|
||||||
if (refreshToken) {
|
err.isAuthError = true;
|
||||||
try {
|
throw err;
|
||||||
const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh';
|
|
||||||
const refreshRes = await fetch(refreshUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refreshRes.ok) {
|
|
||||||
const newTokens = await refreshRes.json();
|
|
||||||
await storageSetCritical("chattz_token", newTokens.access_token);
|
|
||||||
await storageSetCritical("chattz_refresh_token", newTokens.refresh_token);
|
|
||||||
|
|
||||||
// Retry original request with new token
|
|
||||||
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
|
|
||||||
res = await fetch(fullUrl, { ...options, headers });
|
|
||||||
} else {
|
|
||||||
// Both tokens invalid/expired, wipe out to force clear state
|
|
||||||
throw new Error("Refresh token expired or invalid");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await storageRemoveCritical("chattz_token");
|
|
||||||
await storageRemoveCritical("chattz_refresh_token");
|
|
||||||
location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login";
|
|
||||||
throw new Error("Session expired, please log in again");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -336,16 +269,18 @@ function escapeHtml(s) {
|
||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageBody(body) {
|
const GIF_URL_REGEX = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
|
||||||
const escaped = escapeHtml(body);
|
|
||||||
// Detect GIF URLs (simple regex for demo)
|
function normalizeMediaUrl(raw) {
|
||||||
const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
|
try {
|
||||||
if (urlRegex.test(body)) {
|
const url = new URL(raw, location.origin);
|
||||||
return escaped.replace(urlRegex, (url) => {
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
return `<div class="msg-gif"><img src="${url}" loading="lazy" /></div>`;
|
return null;
|
||||||
});
|
}
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return escaped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(size) {
|
function formatBytes(size) {
|
||||||
|
|
@ -362,29 +297,125 @@ function formatBytes(size) {
|
||||||
|
|
||||||
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024;
|
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
function renderAttachments(attachments = []) {
|
function buildMessageBody(body) {
|
||||||
if (!attachments.length) return "";
|
const fragment = document.createDocumentFragment();
|
||||||
|
const text = String(body || "");
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(new RegExp(GIF_URL_REGEX))) {
|
||||||
|
const [rawUrl] = match;
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
if (start > cursor) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(cursor, start)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeUrl = normalizeMediaUrl(rawUrl);
|
||||||
|
if (safeUrl) {
|
||||||
|
const gifWrap = document.createElement("div");
|
||||||
|
gifWrap.className = "msg-gif";
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.loading = "lazy";
|
||||||
|
image.src = safeUrl;
|
||||||
|
gifWrap.appendChild(image);
|
||||||
|
fragment.appendChild(gifWrap);
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(document.createTextNode(rawUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = start + rawUrl.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(cursor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachments(attachments = []) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const safeUrl = normalizeMediaUrl(attachment.media_url);
|
||||||
|
if (!safeUrl) continue;
|
||||||
|
|
||||||
return attachments.map((attachment) => {
|
|
||||||
const url = escapeHtml(attachment.media_url);
|
|
||||||
const mime = attachment.mime_type || "application/octet-stream";
|
const mime = attachment.mime_type || "application/octet-stream";
|
||||||
const fileName = escapeHtml(attachment.original_filename || "file");
|
const fileName = attachment.original_filename || "file";
|
||||||
const size = formatBytes(attachment.size_bytes);
|
const size = formatBytes(attachment.size_bytes);
|
||||||
|
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
return `<a class="msg-attachment msg-attachment-image" href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${fileName}" loading="lazy" /></a>`;
|
const link = document.createElement("a");
|
||||||
|
link.className = "msg-attachment msg-attachment-image";
|
||||||
|
link.href = safeUrl;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = safeUrl;
|
||||||
|
image.alt = fileName;
|
||||||
|
image.loading = "lazy";
|
||||||
|
link.appendChild(image);
|
||||||
|
fragment.appendChild(link);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mime.startsWith("video/")) {
|
if (mime.startsWith("video/")) {
|
||||||
return `<div class="msg-attachment msg-attachment-video"><video controls preload="metadata" src="${url}"></video></div>`;
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "msg-attachment msg-attachment-video";
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.controls = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.src = safeUrl;
|
||||||
|
wrapper.appendChild(video);
|
||||||
|
fragment.appendChild(wrapper);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mime.startsWith("audio/")) {
|
if (mime.startsWith("audio/")) {
|
||||||
return `<div class="msg-attachment msg-attachment-audio"><audio controls preload="metadata" src="${url}"></audio><a href="${url}" target="_blank" rel="noopener noreferrer">${fileName}</a></div>`;
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "msg-attachment msg-attachment-audio";
|
||||||
|
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.controls = true;
|
||||||
|
audio.preload = "metadata";
|
||||||
|
audio.src = safeUrl;
|
||||||
|
wrapper.appendChild(audio);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = safeUrl;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.textContent = fileName;
|
||||||
|
wrapper.appendChild(link);
|
||||||
|
|
||||||
|
fragment.appendChild(wrapper);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<a class="msg-attachment msg-attachment-file" href="${url}" target="_blank" rel="noopener noreferrer"><i data-lucide="file"></i><span>${fileName}</span><small>${size}</small></a>`;
|
const link = document.createElement("a");
|
||||||
}).join("");
|
link.className = "msg-attachment msg-attachment-file";
|
||||||
|
link.href = safeUrl;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
|
||||||
|
const icon = document.createElement("i");
|
||||||
|
icon.setAttribute("data-lucide", "file");
|
||||||
|
link.appendChild(icon);
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = fileName;
|
||||||
|
link.appendChild(name);
|
||||||
|
|
||||||
|
const meta = document.createElement("small");
|
||||||
|
meta.textContent = size;
|
||||||
|
link.appendChild(meta);
|
||||||
|
|
||||||
|
fragment.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMediaUploadLimitError(file) {
|
function showMediaUploadLimitError(file) {
|
||||||
|
|
@ -546,50 +577,103 @@ function renderChannels() {
|
||||||
pList.style.paddingLeft = "24px";
|
pList.style.paddingLeft = "24px";
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
const pRow = document.createElement("div");
|
const pRow = document.createElement("div");
|
||||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
pRow.className = `channel-row voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||||
|
pRow.style.display = "block";
|
||||||
pRow.style.padding = "2px 8px";
|
pRow.style.padding = "2px 8px";
|
||||||
pRow.style.flexWrap = "wrap";
|
pRow.style.flexWrap = "wrap";
|
||||||
|
|
||||||
let sliderHtml = '';
|
const topRow = document.createElement("div");
|
||||||
if (p.user_id !== state.me.id) {
|
topRow.style.display = "flex";
|
||||||
const vol = state.userVolumes.get(p.user_id) ?? 1.0;
|
topRow.style.alignItems = "center";
|
||||||
const isVisible = state.voice.visibleVolumeSliders.has(p.user_id);
|
topRow.style.gap = "8px";
|
||||||
sliderHtml = `
|
topRow.style.width = "100%";
|
||||||
<div class="user-volume-control ${isVisible ? 'show-volume' : ''}" data-user-id="${p.user_id}">
|
|
||||||
<i data-lucide="volume-2" style="width: 12px; height: 12px; opacity: 0.6;"></i>
|
const avatar = document.createElement("div");
|
||||||
<input type="range" min="0" max="2" step="0.1" value="${vol}" class="volume-slider">
|
avatar.className = "avatar";
|
||||||
<span class="vol-pct">${Math.round(vol * 100)}%</span>
|
avatar.style.width = "20px";
|
||||||
</div>
|
avatar.style.height = "20px";
|
||||||
`;
|
avatar.style.fontSize = "10px";
|
||||||
|
avatar.textContent = shortName(p.display_name);
|
||||||
|
topRow.appendChild(avatar);
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.style.flex = "1";
|
||||||
|
name.style.overflow = "hidden";
|
||||||
|
name.style.textOverflow = "ellipsis";
|
||||||
|
name.textContent = p.display_name;
|
||||||
|
topRow.appendChild(name);
|
||||||
|
|
||||||
|
const isRemoteParticipant = p.user_id !== state.me.id;
|
||||||
|
if (isRemoteParticipant) {
|
||||||
|
const volumeToggle = document.createElement("button");
|
||||||
|
volumeToggle.type = "button";
|
||||||
|
volumeToggle.title = "Toggle volume slider";
|
||||||
|
volumeToggle.style.display = "grid";
|
||||||
|
volumeToggle.style.placeItems = "center";
|
||||||
|
volumeToggle.style.width = "20px";
|
||||||
|
volumeToggle.style.height = "20px";
|
||||||
|
volumeToggle.style.color = "var(--text-muted)";
|
||||||
|
volumeToggle.innerHTML = '<i data-lucide="volume-2" style="width: 14px; height: 14px;"></i>';
|
||||||
|
topRow.appendChild(volumeToggle);
|
||||||
|
|
||||||
|
volumeToggle.addEventListener('click', (e) => {
|
||||||
|
toggleVolumeSlider(e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pRow.innerHTML = `
|
if (p.is_muted) {
|
||||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
const muteIcon = document.createElement("i");
|
||||||
<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div>
|
muteIcon.setAttribute("data-lucide", "mic-off");
|
||||||
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span>
|
muteIcon.className = "voice-muted-icon";
|
||||||
${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}
|
topRow.appendChild(muteIcon);
|
||||||
</div>
|
}
|
||||||
${sliderHtml}
|
|
||||||
`;
|
pRow.appendChild(topRow);
|
||||||
|
|
||||||
|
const volumeControl = document.createElement("div");
|
||||||
|
volumeControl.className = `user-volume-control ${state.voice.visibleVolumeSliders.has(p.user_id) ? 'show-volume' : ''}`;
|
||||||
|
volumeControl.dataset.userId = p.user_id;
|
||||||
|
|
||||||
|
if (isRemoteParticipant) {
|
||||||
|
const volumeIcon = document.createElement("i");
|
||||||
|
volumeIcon.setAttribute("data-lucide", "volume-2");
|
||||||
|
volumeIcon.style.width = "12px";
|
||||||
|
volumeIcon.style.height = "12px";
|
||||||
|
volumeIcon.style.opacity = "0.6";
|
||||||
|
volumeControl.appendChild(volumeIcon);
|
||||||
|
|
||||||
|
const slider = document.createElement("input");
|
||||||
|
slider.type = "range";
|
||||||
|
slider.min = "0";
|
||||||
|
slider.max = "2";
|
||||||
|
slider.step = "0.1";
|
||||||
|
slider.value = String(state.userVolumes.get(p.user_id) ?? 1.0);
|
||||||
|
slider.className = "volume-slider";
|
||||||
|
volumeControl.appendChild(slider);
|
||||||
|
|
||||||
|
const volumePct = document.createElement("span");
|
||||||
|
volumePct.className = "vol-pct";
|
||||||
|
volumePct.textContent = `${Math.round((state.userVolumes.get(p.user_id) ?? 1.0) * 100)}%`;
|
||||||
|
volumeControl.appendChild(volumePct);
|
||||||
|
|
||||||
const slider = pRow.querySelector('.volume-slider');
|
|
||||||
if (slider) {
|
|
||||||
slider.addEventListener('input', (e) => {
|
slider.addEventListener('input', (e) => {
|
||||||
const val = parseFloat(e.target.value);
|
const val = parseFloat(e.target.value);
|
||||||
state.userVolumes.set(p.user_id, val);
|
state.userVolumes.set(p.user_id, val);
|
||||||
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`;
|
volumePct.textContent = `${Math.round(val * 100)}%`;
|
||||||
|
|
||||||
const gainNode = state.voice.peerGainNodes.get(p.user_id);
|
const gainNode = state.voice.peerGainNodes.get(p.user_id);
|
||||||
if (gainNode) {
|
if (gainNode && state.voice.audioContext) {
|
||||||
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
|
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Stop propagation to prevent joining channel again when clicking slider
|
|
||||||
slider.addEventListener('click', (e) => e.stopPropagation());
|
volumeControl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
volumeControl.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
|
pRow.appendChild(volumeControl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVolumeSlider = (e) => {
|
const toggleVolumeSlider = (e) => {
|
||||||
if (p.user_id === state.me.id) return;
|
if (!isRemoteParticipant) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (state.voice.visibleVolumeSliders.has(p.user_id)) {
|
if (state.voice.visibleVolumeSliders.has(p.user_id)) {
|
||||||
|
|
@ -603,6 +687,9 @@ function renderChannels() {
|
||||||
// Electron can swallow contextmenu events on some platforms; use
|
// Electron can swallow contextmenu events on some platforms; use
|
||||||
// right-button mousedown as a reliable fallback for slider toggle.
|
// right-button mousedown as a reliable fallback for slider toggle.
|
||||||
pRow.addEventListener('contextmenu', toggleVolumeSlider);
|
pRow.addEventListener('contextmenu', toggleVolumeSlider);
|
||||||
|
pRow.addEventListener('auxclick', (e) => {
|
||||||
|
if (e.button === 2) toggleVolumeSlider(e);
|
||||||
|
});
|
||||||
pRow.addEventListener('mousedown', (e) => {
|
pRow.addEventListener('mousedown', (e) => {
|
||||||
if (e.button === 2) toggleVolumeSlider(e);
|
if (e.button === 2) toggleVolumeSlider(e);
|
||||||
});
|
});
|
||||||
|
|
@ -663,25 +750,40 @@ function renderMessages(messages) {
|
||||||
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
||||||
|
|
||||||
const displayName = m.author_display_name || "Unknown User";
|
const displayName = m.author_display_name || "Unknown User";
|
||||||
const formattedBody = formatMessageBody(m.body || "");
|
const content = document.createElement("div");
|
||||||
const attachmentsHtml = renderAttachments(m.attachments);
|
content.className = "msg-content";
|
||||||
const bodyHtml = formattedBody ? `<div class="msg-body">${formattedBody}</div>` : "";
|
|
||||||
const contentHtml = `${bodyHtml}${attachmentsHtml}`;
|
|
||||||
|
|
||||||
if (isGrouped) {
|
if (!isGrouped) {
|
||||||
row.innerHTML = `<div class="msg-content">${contentHtml}</div>`;
|
const avatar = document.createElement("div");
|
||||||
} else {
|
avatar.className = "msg-avatar";
|
||||||
row.innerHTML = `
|
avatar.textContent = shortName(displayName);
|
||||||
<div class="msg-avatar">${shortName(displayName)}</div>
|
row.appendChild(avatar);
|
||||||
<div class="msg-content">
|
|
||||||
<div class="msg-header">
|
const header = document.createElement("div");
|
||||||
<span class="msg-author">${escapeHtml(displayName)}</span>
|
header.className = "msg-header";
|
||||||
<span class="msg-time">${formatDate(m.created_at)}</span>
|
|
||||||
</div>
|
const author = document.createElement("span");
|
||||||
${contentHtml}
|
author.className = "msg-author";
|
||||||
</div>
|
author.textContent = displayName;
|
||||||
`;
|
header.appendChild(author);
|
||||||
|
|
||||||
|
const time = document.createElement("span");
|
||||||
|
time.className = "msg-time";
|
||||||
|
time.textContent = formatDate(m.created_at);
|
||||||
|
header.appendChild(time);
|
||||||
|
|
||||||
|
content.appendChild(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m.body) {
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "msg-body";
|
||||||
|
body.appendChild(buildMessageBody(m.body));
|
||||||
|
content.appendChild(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.appendChild(buildAttachments(m.attachments));
|
||||||
|
row.appendChild(content);
|
||||||
el.messageList.appendChild(row);
|
el.messageList.appendChild(row);
|
||||||
lastAuthorId = m.author_user_id;
|
lastAuthorId = m.author_user_id;
|
||||||
lastTime = mDate;
|
lastTime = mDate;
|
||||||
|
|
@ -784,8 +886,7 @@ async function createInviteLink() {
|
||||||
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
||||||
});
|
});
|
||||||
// Use the backend URL if available, otherwise fallback to current origin
|
// Use the backend URL if available, otherwise fallback to current origin
|
||||||
const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin;
|
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
|
||||||
const link = `${base}/?invite=${encodeURIComponent(invite.code)}`;
|
|
||||||
|
|
||||||
// Use Electron's native clipboard API if available (defined in preload.js)
|
// Use Electron's native clipboard API if available (defined in preload.js)
|
||||||
if (window.electronAPI && window.electronAPI.copyToClipboard) {
|
if (window.electronAPI && window.electronAPI.copyToClipboard) {
|
||||||
|
|
@ -871,13 +972,15 @@ async function refreshVoicePresence() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function startVoicePresencePolling() {
|
function startVoicePresencePolling() {
|
||||||
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
|
stopVoicePresencePolling();
|
||||||
state.voicePresencePollId = setInterval(() => {
|
state.voicePresencePollId = setInterval(() => {
|
||||||
|
if (!state.sessionActive) return;
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initChatWs() {
|
function initChatWs() {
|
||||||
|
if (!state.sessionActive) return;
|
||||||
if (state.chatWs) state.chatWs.close();
|
if (state.chatWs) state.chatWs.close();
|
||||||
const wsUrl = getWsUrl('/ws');
|
const wsUrl = getWsUrl('/ws');
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
@ -920,6 +1023,7 @@ function initChatWs() {
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
if (!state.sessionActive) return;
|
||||||
console.log("Chat WS closed, reconnecting...");
|
console.log("Chat WS closed, reconnecting...");
|
||||||
setTimeout(initChatWs, 3000);
|
setTimeout(initChatWs, 3000);
|
||||||
};
|
};
|
||||||
|
|
@ -936,7 +1040,24 @@ function shouldInitiateOffer(peerId) {
|
||||||
return state.me.id > peerId;
|
return state.me.id > peerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectPeerAudioNodes(peerId) {
|
||||||
|
const nodes = state.voice.peerAudioNodes.get(peerId);
|
||||||
|
if (!nodes) return;
|
||||||
|
|
||||||
|
for (const node of [nodes.sourceNode, nodes.compressorNode, nodes.gainNode]) {
|
||||||
|
try {
|
||||||
|
node?.disconnect();
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.voice.peerAudioNodes.delete(peerId);
|
||||||
|
state.voice.peerGainNodes.delete(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
function stopAndClearAudioPipeline() {
|
function stopAndClearAudioPipeline() {
|
||||||
|
for (const peerId of state.voice.peerAudioNodes.keys()) {
|
||||||
|
disconnectPeerAudioNodes(peerId);
|
||||||
|
}
|
||||||
if (state.voice.deepFilterProcessor) {
|
if (state.voice.deepFilterProcessor) {
|
||||||
state.voice.deepFilterProcessor.destroy();
|
state.voice.deepFilterProcessor.destroy();
|
||||||
state.voice.deepFilterProcessor = null;
|
state.voice.deepFilterProcessor = null;
|
||||||
|
|
@ -1162,7 +1283,7 @@ async function createLocalVoiceStream() {
|
||||||
sampleRate: 48000,
|
sampleRate: 48000,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: false,
|
noiseSuppression: false,
|
||||||
autoGainControl: false,
|
autoGainControl: true,
|
||||||
},
|
},
|
||||||
video: false,
|
video: false,
|
||||||
};
|
};
|
||||||
|
|
@ -1285,16 +1406,27 @@ function ensurePeerConnection(peerId) {
|
||||||
const ctx = state.voice.audioContext;
|
const ctx = state.voice.audioContext;
|
||||||
if (ctx.state === 'suspended') ctx.resume();
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
|
||||||
|
disconnectPeerAudioNodes(peerId);
|
||||||
|
|
||||||
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
|
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
|
||||||
|
const compressorNode = ctx.createDynamicsCompressor();
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
compressorNode.threshold.setValueAtTime(-24, ctx.currentTime);
|
||||||
|
compressorNode.knee.setValueAtTime(18, ctx.currentTime);
|
||||||
|
compressorNode.ratio.setValueAtTime(4, ctx.currentTime);
|
||||||
|
compressorNode.attack.setValueAtTime(0.003, ctx.currentTime);
|
||||||
|
compressorNode.release.setValueAtTime(0.25, ctx.currentTime);
|
||||||
|
|
||||||
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
|
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
|
||||||
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
|
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
|
||||||
|
|
||||||
sourceNode.connect(gainNode);
|
sourceNode.connect(compressorNode);
|
||||||
|
compressorNode.connect(gainNode);
|
||||||
gainNode.connect(ctx.destination);
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
state.voice.peerGainNodes.set(peerId, gainNode);
|
state.voice.peerGainNodes.set(peerId, gainNode);
|
||||||
|
state.voice.peerAudioNodes.set(peerId, { sourceNode, compressorNode, gainNode });
|
||||||
|
|
||||||
// Mute the original element as we play through Web Audio destination
|
// Mute the original element as we play through Web Audio destination
|
||||||
audio.volume = 0;
|
audio.volume = 0;
|
||||||
|
|
@ -1479,6 +1611,7 @@ async function joinVoice() {
|
||||||
pc.close();
|
pc.close();
|
||||||
state.voice.peerConnections.delete(msg.user_id);
|
state.voice.peerConnections.delete(msg.user_id);
|
||||||
}
|
}
|
||||||
|
disconnectPeerAudioNodes(msg.user_id);
|
||||||
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||||
} else if (msg.type === "signal") {
|
} else if (msg.type === "signal") {
|
||||||
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||||
|
|
@ -1505,7 +1638,7 @@ async function joinVoice() {
|
||||||
});
|
});
|
||||||
renderChannels();
|
renderChannels();
|
||||||
} else if (msg.type === "play_sound") {
|
} else if (msg.type === "play_sound") {
|
||||||
const audio = new Audio(msg.sound_url);
|
const audio = new Audio(msg.media_url);
|
||||||
audio.play().catch(console.error);
|
audio.play().catch(console.error);
|
||||||
}
|
}
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
|
|
@ -1680,11 +1813,22 @@ function renderSounds(sounds) {
|
||||||
// Only show delete button if user is creator or owner
|
// Only show delete button if user is creator or owner
|
||||||
const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id));
|
const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id));
|
||||||
|
|
||||||
item.innerHTML = `
|
const icon = document.createElement('div');
|
||||||
<div class="sound-icon">${sound.icon}</div>
|
icon.className = 'sound-icon';
|
||||||
<div class="sound-name">${sound.name}</div>
|
icon.textContent = sound.icon;
|
||||||
${canDelete ? `<button class="sound-delete" title="Delete sound"><i data-lucide="x"></i></button>` : ''}
|
const name = document.createElement('div');
|
||||||
`;
|
name.className = 'sound-name';
|
||||||
|
name.textContent = sound.name;
|
||||||
|
item.appendChild(icon);
|
||||||
|
item.appendChild(name);
|
||||||
|
|
||||||
|
if (canDelete) {
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'sound-delete';
|
||||||
|
deleteBtn.title = 'Delete sound';
|
||||||
|
deleteBtn.innerHTML = '<i data-lucide="x"></i>';
|
||||||
|
item.appendChild(deleteBtn);
|
||||||
|
}
|
||||||
|
|
||||||
item.onclick = (e) => {
|
item.onclick = (e) => {
|
||||||
if (e.target.closest('.sound-delete')) {
|
if (e.target.closest('.sound-delete')) {
|
||||||
|
|
@ -1694,7 +1838,7 @@ function renderSounds(sounds) {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playRemoteSound(sound.file_path);
|
playRemoteSound(sound.id, sound.media_url);
|
||||||
};
|
};
|
||||||
|
|
||||||
el.soundboardGrid.appendChild(item);
|
el.soundboardGrid.appendChild(item);
|
||||||
|
|
@ -1705,27 +1849,19 @@ function renderSounds(sounds) {
|
||||||
async function deleteSound(soundId) {
|
async function deleteSound(soundId) {
|
||||||
if (!state.selectedGuildId) return;
|
if (!state.selectedGuildId) return;
|
||||||
try {
|
try {
|
||||||
const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`;
|
await api(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { method: 'DELETE' });
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json();
|
|
||||||
throw new Error(err.error || 'Delete failed');
|
|
||||||
}
|
|
||||||
await loadSounds();
|
await loadSounds();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playRemoteSound(url) {
|
function playRemoteSound(soundId, mediaUrl) {
|
||||||
const fullUrl = API_BASE_URL && !url.startsWith('http') ? `${API_BASE_URL}${url}` : url;
|
|
||||||
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
||||||
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl }));
|
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_id: soundId }));
|
||||||
}
|
}
|
||||||
// Also play locally immediately
|
// Also play locally immediately
|
||||||
const audio = new Audio(fullUrl);
|
const audio = new Audio(mediaUrl);
|
||||||
audio.play().catch(console.error);
|
audio.play().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1790,41 +1926,23 @@ document.addEventListener('click', resetInactivityTimer);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
await hydrateDesktopStorage();
|
|
||||||
initUpdater();
|
initUpdater();
|
||||||
|
|
||||||
// 1. Handle tokens in URL (from successful login redirects)
|
|
||||||
try {
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
|
||||||
const jwtToken = searchParams.get("token");
|
|
||||||
const refreshToken = searchParams.get("refresh_token");
|
|
||||||
|
|
||||||
if (jwtToken || refreshToken) {
|
|
||||||
if (jwtToken) await storageSetCritical("chattz_token", jwtToken);
|
|
||||||
if (refreshToken) await storageSetCritical("chattz_refresh_token", refreshToken);
|
|
||||||
|
|
||||||
searchParams.delete("token");
|
|
||||||
searchParams.delete("refresh_token");
|
|
||||||
const nextQuery = searchParams.toString();
|
|
||||||
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
|
||||||
history.replaceState(null, "", nextUrl);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse token from URL:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Global Button Handlers
|
// 2. Global Button Handlers
|
||||||
el.loginBtn.onclick = () => {
|
el.loginBtn.onclick = () => {
|
||||||
const loginPath = "/auth/login";
|
el.status.textContent = "";
|
||||||
location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath;
|
location.href = "/auth/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
el.logoutBtn.onclick = async () => {
|
el.logoutBtn.onclick = async () => {
|
||||||
await leaveVoice();
|
try {
|
||||||
await storageRemoveCritical("chattz_token");
|
await api("/auth/logout", { method: "POST" });
|
||||||
await storageRemoveCritical("chattz_refresh_token");
|
} catch (e) {
|
||||||
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
|
if (!e?.isAuthError) {
|
||||||
location.reload();
|
console.warn("logout request failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await showLoggedOutState("Signed out.");
|
||||||
};
|
};
|
||||||
|
|
||||||
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
||||||
|
|
@ -1970,8 +2088,11 @@ async function init() {
|
||||||
el.soundSubmitBtn.disabled = true;
|
el.soundSubmitBtn.disabled = true;
|
||||||
el.soundSubmitBtn.textContent = 'Uploading...';
|
el.soundSubmitBtn.textContent = 'Uploading...';
|
||||||
try {
|
try {
|
||||||
const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`;
|
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, {
|
||||||
const response = await fetch(url, { method: 'POST', body: formData });
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Upload failed'); }
|
if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Upload failed'); }
|
||||||
el.soundModal.classList.add("hidden");
|
el.soundModal.classList.add("hidden");
|
||||||
el.soundForm.reset();
|
el.soundForm.reset();
|
||||||
|
|
@ -2063,8 +2184,6 @@ async function init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadMediaFile(file) {
|
async function uploadMediaFile(file) {
|
||||||
const token = storageGet("chattz_token");
|
|
||||||
if (!token) throw new Error("You need to log in again.");
|
|
||||||
if (file.size > MAX_MEDIA_UPLOAD_BYTES) {
|
if (file.size > MAX_MEDIA_UPLOAD_BYTES) {
|
||||||
showMediaUploadLimitError(file);
|
showMediaUploadLimitError(file);
|
||||||
return;
|
return;
|
||||||
|
|
@ -2080,16 +2199,15 @@ async function init() {
|
||||||
el.mediaUploadBtn.disabled = true;
|
el.mediaUploadBtn.disabled = true;
|
||||||
el.mediaUploadBtn.title = "Uploading...";
|
el.mediaUploadBtn.title = "Uploading...";
|
||||||
|
|
||||||
const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(fullUrl, {
|
const response = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": file.type || "application/octet-stream",
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
"X-File-Name": file.name,
|
"X-File-Name": file.name,
|
||||||
},
|
},
|
||||||
body: file,
|
body: file,
|
||||||
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -2116,10 +2234,12 @@ async function init() {
|
||||||
// 6. Initial Data Loading & App Start
|
// 6. Initial Data Loading & App Start
|
||||||
try {
|
try {
|
||||||
state.me = await api("/me");
|
state.me = await api("/me");
|
||||||
|
state.sessionActive = true;
|
||||||
if (state.me && state.me.display_name) {
|
if (state.me && state.me.display_name) {
|
||||||
el.userName.textContent = state.me.display_name;
|
el.userName.textContent = state.me.display_name;
|
||||||
el.userAvatar.textContent = shortName(state.me.display_name);
|
el.userAvatar.textContent = shortName(state.me.display_name);
|
||||||
}
|
}
|
||||||
|
el.status.textContent = "";
|
||||||
el.authScreen.classList.add("hidden");
|
el.authScreen.classList.add("hidden");
|
||||||
el.main.classList.remove("hidden");
|
el.main.classList.remove("hidden");
|
||||||
|
|
||||||
|
|
@ -2144,7 +2264,8 @@ async function init() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const online = await api("/presence");
|
const online = await api("/presence");
|
||||||
state.onlineUsers = new Set(online);
|
state.onlineUsers = new Set(online.filter((entry) => entry.online).map((entry) => entry.user_id));
|
||||||
|
state.idleUsers = new Set(online.filter((entry) => entry.idle).map((entry) => entry.user_id));
|
||||||
} catch (err) { console.warn("presence sync failed", err); }
|
} catch (err) { console.warn("presence sync failed", err); }
|
||||||
|
|
||||||
if (state.guilds.length > 0) {
|
if (state.guilds.length > 0) {
|
||||||
|
|
@ -2178,9 +2299,10 @@ async function init() {
|
||||||
|
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!err?.isAuthError) {
|
||||||
console.error("init failed", err);
|
console.error("init failed", err);
|
||||||
el.authScreen.classList.remove("hidden");
|
await showLoggedOutState("Unable to load the app.");
|
||||||
el.main.classList.add("hidden");
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2280,5 +2402,4 @@ function initUpdater() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config fetcher will trigger init()
|
init();
|
||||||
initializeConfig();
|
|
||||||
|
|
|
||||||
12
static/vendor/lucide.min.js
vendored
Normal file
12
static/vendor/lucide.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue