Compare commits

..

No commits in common. "main" and "0.0.52" have entirely different histories.

29 changed files with 917 additions and 3778 deletions

View file

@ -1,6 +1,5 @@
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
@ -17,9 +16,13 @@ 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
MEDIA_BASE_URL=https://media.example.com R2_PUBLIC_BASE_URL=https://media.example.com

51
Cargo.lock generated
View file

@ -675,14 +675,13 @@ 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",
@ -1649,6 +1648,21 @@ 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"
@ -1811,6 +1825,16 @@ 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"
@ -1943,6 +1967,16 @@ 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"
@ -2731,6 +2765,18 @@ 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"
@ -3085,6 +3131,7 @@ 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",

View file

@ -10,15 +10,14 @@ 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", "mock"] } sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
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 = ["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"] }

View file

@ -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`)
- HttpOnly session cookie auth - Signed 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,11 +41,6 @@ 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
@ -60,7 +55,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: `${APP_BASE_URL}/auth/callback` - Redirect URI: `http://localhost:3000/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.
@ -96,7 +91,6 @@ 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
@ -104,7 +98,6 @@ 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)

View file

@ -6,9 +6,12 @@
<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="../static/vendor/lucide.min.js"></script> <script src="https://unpkg.com/lucide@latest"></script>
</head> </head>
<body> <body>
@ -266,16 +269,6 @@
</div> </div>
</div> </div>
<div id="upload-limit-modal" class="modal-container hidden">
<div class="modal">
<h2>Upload Too Large</h2>
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
<div class="modal-footer">
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
</div>
</div>
</div>
<script type="module" src="app.js?v=20260227-shared-core-1"></script> <script type="module" src="app.js?v=20260227-shared-core-1"></script>
</body> </body>

View file

@ -1,20 +1,18 @@
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) => onIpc('update-state', callback), onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)),
onUpdateAvailable: (callback) => onIpc('update-available', callback), onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)),
onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)),
onUpdateError: (callback) => onIpc('update-error', callback) onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error))
}); });

237
main.js
View file

@ -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 packageJson = require('./package.json'); const fs = require('fs');
const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000; const url = require('url');
const updateState = { const updateState = {
status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error
info: null, info: null,
error: null, error: null,
}; };
@ -19,43 +19,41 @@ 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;
@ -85,62 +83,9 @@ 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,
@ -149,6 +94,7 @@ 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')
} }
@ -157,33 +103,32 @@ 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) => {
const origin = webContentsOrigin(webContents); if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') {
if (origin !== backendOrigin) return false; return true;
return permission === 'media' || permission === 'clipboard-write'; }
return false;
}); });
sess.setPermissionRequestHandler((webContents, permission, callback) => { sess.setPermissionRequestHandler((webContents, permission, callback) => {
const origin = webContentsOrigin(webContents); if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') {
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); callback(null); // Reject safely
} }
}).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);
@ -191,9 +136,55 @@ function createWindow() {
}); });
}); });
win.loadURL(backendUrl).catch((err) => { const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '');
console.error(`Failed to load desktop app: ${err}`); const indexPath = path.join(__dirname, 'desktop', 'index.html');
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
@ -212,11 +203,40 @@ 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');
@ -267,7 +287,6 @@ 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();
@ -275,14 +294,13 @@ app.whenReady().then(() => {
}); });
ipcMain.on('quit-and-install', () => { ipcMain.on('quit-and-install', () => {
installDownloadedUpdate(); autoUpdater.quitAndInstall();
}); });
// 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) {
@ -296,18 +314,3 @@ 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;
}
}

View file

@ -5,30 +5,26 @@ 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: updaterMarkup, desktop_updater: '',
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: updaterMarkup, desktop_updater: `<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>`,
outPath: path.join(root, 'desktop', 'index.html'), outPath: path.join(root, 'desktop', 'index.html'),
}, },
}; };

View file

@ -5,9 +5,12 @@
<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="{{lucide_src}}"></script> <script src="https://unpkg.com/lucide@latest"></script>
</head> </head>
<body> <body>
@ -263,16 +266,6 @@
</div> </div>
</div> </div>
<div id="upload-limit-modal" class="modal-container hidden">
<div class="modal">
<h2>Upload Too Large</h2>
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
<div class="modal-footer">
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
</div>
</div>
</div>
<script type="module" src="{{app_src}}"></script> <script type="module" src="{{app_src}}"></script>
</body> </body>

View file

@ -1,25 +1,33 @@
use anyhow::{Result, anyhow}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow};
use axum::{ use axum::{
Json, Json,
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{HeaderMap, StatusCode, request::Parts}, http::{StatusCode, request::Parts},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Serialize; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::{AppState, db}; use crate::{AppState, db};
pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
pub const SESSION_COOKIE: &str = "chattz_session"; const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes
const SESSION_TTL_DAYS: i64 = 30; const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days
#[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)]
@ -36,13 +44,6 @@ 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,
@ -56,13 +57,6 @@ 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)]
@ -90,25 +84,29 @@ impl From<anyhow::Error> for ApiError {
impl<S> FromRequestParts<S> for AuthUser impl<S> FromRequestParts<S> for AuthUser
where where
AppState: FromRef<S>, AppState: axum::extract::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 user_id = db::touch_active_session(&app.db, &session_id) let token = read_bearer_token(parts)
.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("invalid or expired session"))? .map_err(|_| ApiError::unauthorized("session user not found"))?;
.ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?; if !exists {
return Err(ApiError::unauthorized("session user not found"));
}
Ok(Self { Ok(Self { id: user_id })
id: user_id,
session_id,
})
} }
} }
@ -116,14 +114,6 @@ 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}",
@ -133,32 +123,76 @@ 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}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}", "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
name = SESSION_COOKIE, name = OAUTH_STATE_COOKIE,
ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(),
secure_flag = if secure { "; Secure" } else { "" } secure_flag = if secure { "; Secure" } else { "" }
) )
} }
pub fn clear_session_cookie(secure: bool) -> String { pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<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 == cookie_name).then(|| value.to_string()) (key == OAUTH_STATE_COOKIE).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 {
@ -167,113 +201,37 @@ pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str)
Ok(()) Ok(())
} }
pub fn user_agent_hash(headers: &HeaderMap) -> Option<String> { fn read_bearer_token(parts: &Parts) -> Option<String> {
header_hash(headers, axum::http::header::USER_AGENT.as_str()) let raw = parts
} .headers
.get(axum::http::header::AUTHORIZATION)?
pub fn ip_hash(headers: &HeaderMap) -> Option<String> { .to_str()
headers .ok()?;
.get("x-forwarded-for") if raw.starts_with("Bearer ") {
.and_then(|v| v.to_str().ok()) Some(raw["Bearer ".len()..].trim().to_string())
.and_then(|raw| raw.split(',').next().map(str::trim)) } else {
.filter(|value| !value.is_empty()) None
.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 clear_cookie(name: &str, secure: bool, same_site: &str) -> String { fn read_query_token(parts: &Parts) -> Option<String> {
format!( let query = parts.uri.query()?;
"{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}",
secure_flag = if secure { "; Secure" } else { "" }
)
}
#[cfg(test)] // Simple query param parsing without pulling in url::Url overhead
mod tests { for pair in query.split('&') {
use super::{ let mut kv = pair.splitn(2, '=');
SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches, let key = kv.next()?;
read_cookie_from_headers, let value = kv.next()?;
}; if key == "token" {
use axum::http::{HeaderMap, header}; return Some(value.to_string());
#[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"));
} }
} }
None
}
fn now_ts() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
}

View file

@ -1,30 +1,27 @@
use std::collections::HashMap; use crate::AppState;
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, Debug, PartialEq, Eq)] #[derive(Serialize, Clone)]
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 -> connection_id -> client // user_id -> client
clients: RwLock<HashMap<Uuid, HashMap<Uuid, ChatClient>>>, clients: RwLock<HashMap<Uuid, ChatClient>>,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@ -48,113 +45,85 @@ 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( pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender<ServerEvent>) {
&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;
let previous = aggregate_presence(clients.get(&user_id), user_id); clients.insert(user_id, ChatClient { tx, is_idle: false });
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, connection_id: Uuid) -> Option<OnlineUser> { pub async fn remove_client(&self, user_id: Uuid) {
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);
} }
}
let current = aggregate_presence(clients.get(&user_id), user_id); pub async fn get_online_users(&self) -> Vec<OnlineUser> {
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;
visible_user_ids clients
.iter() .iter()
.filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id)) .map(|(id, client)| OnlineUser {
.filter(|presence| presence.online) user_id: *id,
idle: client.is_idle,
})
.collect() .collect()
} }
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) { pub async fn broadcast_all(&self, event: ServerEvent) {
let clients = self.clients.read().await; let clients = self.clients.read().await;
for user_id in user_ids { for client in clients.values() {
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 broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
let clients = self.clients.read().await; let clients = self.clients.read().await;
if let Some(connections) = clients.get(&user_id) { if let Some(client) = clients.get(&user_id) {
for client in connections.values() { let _ = client.tx.send(event);
}
}
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
let clients = self.clients.read().await;
for user_id in user_ids {
if let Some(client) = clients.get(&user_id) {
let _ = client.tx.send(event.clone()); let _ = client.tx.send(event.clone());
} }
} }
} }
pub async fn set_idle_status( pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) {
&self,
user_id: Uuid,
connection_id: Uuid,
is_idle: bool,
) -> Option<OnlineUser> {
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)
&& let Some(client) = connections.get_mut(&connection_id)
{ {
let mut clients = self.clients.write().await;
if let Some(client) = clients.get_mut(&user_id) {
client.is_idle = is_idle; client.is_idle = is_idle;
} }
}
let current = aggregate_presence(clients.get(&user_id), user_id); self.broadcast_all(ServerEvent::UserPresence {
presence_delta(previous, current) user_id,
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();
if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await { state.chat.add_client(user_id, tx).await;
if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await {
state state
.chat .chat
.broadcast_to_many( .broadcast_all(ServerEvent::UserPresence {
visible_user_ids, user_id,
ServerEvent::UserPresence { online: true,
user_id: presence.user_id, idle: false,
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 {
@ -173,24 +142,8 @@ 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 state.chat.set_idle_status(user_id, is_idle).await;
.chat
.broadcast_to_many(
visible_user_ids,
ServerEvent::UserPresence {
user_id: presence.user_id,
online: presence.online,
idle: presence.idle,
},
)
.await;
} }
} }
_ => {} _ => {}
@ -198,157 +151,13 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
} }
send_task.abort(); send_task.abort();
if let Some(presence) = state.chat.remove_client(user_id, connection_id).await state.chat.remove_client(user_id).await;
&& let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await
{
state state
.chat .chat
.broadcast_to_many( .broadcast_all(ServerEvent::UserPresence {
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,
}) })
);
}
#[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; .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"
));
}
} }

View file

@ -1,11 +1,8 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result};
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,
@ -14,7 +11,8 @@ 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_cookie_secure: bool, pub session_secret: String,
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>,
@ -33,17 +31,11 @@ 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")?,
@ -53,7 +45,11 @@ 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_cookie_secure: requires_secure_cookie(&app_url)?, session_secret: required("SESSION_SECRET")?,
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"),
@ -69,7 +65,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("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL")); let public_base_url = 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()
@ -85,9 +81,7 @@ 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: trim_trailing_slash( public_base_url: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?,
&public_base_url.context("missing env var MEDIA_BASE_URL")?,
),
})) }))
} }
@ -115,105 +109,3 @@ 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
View file

@ -1,17 +1,15 @@
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,
Statement, TransactionTrait, sea_query::OnConflict, 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, sessions, attachments, channels, direct_messages, guild_members, guilds, invites, messages,
soundboard_sounds, users, soundboard_sounds, users,
}, },
models::{ models::{
@ -28,88 +26,6 @@ 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,
@ -190,58 +106,18 @@ 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(&txn) .exec_with_returning(db)
.await?; .await?;
guild_members::Entity::insert(guild_members::ActiveModel { guild_members::Entity::insert(guild_members::ActiveModel {
@ -257,13 +133,17 @@ pub async fn create_guild(
.do_nothing() .do_nothing()
.to_owned(), .to_owned(),
) )
.exec(&txn) .exec(db)
.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,
@ -317,12 +197,7 @@ 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() let invite = invites::Entity::find_by_id(code.to_string())
.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"))?;
@ -922,10 +797,7 @@ pub async fn create_sound(
created_by_user_id: Uuid, created_by_user_id: Uuid,
name: &str, name: &str,
icon: &str, icon: &str,
object_key: &str, file_path: &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()),
@ -933,13 +805,7 @@ 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()),
object_key: Set(Some(object_key.to_string())), file_path: Set(file_path.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)
@ -953,11 +819,6 @@ 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(())
@ -969,321 +830,8 @@ 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,
media_url: model.media_url, file_path: model.file_path,
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}");
}
}

View file

@ -5,6 +5,5 @@ 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;

View file

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

View file

@ -9,11 +9,7 @@ pub struct Model {
pub guild_id: Uuid, pub guild_id: Uuid,
pub name: String, pub name: String,
pub icon: String, pub icon: String,
pub object_key: Option<String>, pub file_path: 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,
} }

View file

@ -1,7 +1,6 @@
use axum::{ use axum::{
Json, Router, Json, Router,
body::Bytes, extract::{Multipart, Path, Query, State, WebSocketUpgrade},
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
http::{HeaderMap, StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect}, response::{Html, IntoResponse, Redirect},
routing::{get, post}, routing::{get, post},
@ -17,12 +16,14 @@ 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))
@ -34,7 +35,6 @@ pub fn routes() -> Router<AppState> {
"/dms/{other_user_id}/attachments", "/dms/{other_user_id}/attachments",
post(upload_dm_attachment), post(upload_dm_attachment),
) )
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route("/presence", get(presence_list)) .route("/presence", get(presence_list))
.route("/rtc-config", get(rtc_config)) .route("/rtc-config", get(rtc_config))
.route("/guilds", get(list_guilds).post(create_guild)) .route("/guilds", get(list_guilds).post(create_guild))
@ -50,7 +50,6 @@ pub fn routes() -> Router<AppState> {
"/guilds/{guild_id}/sounds", "/guilds/{guild_id}/sounds",
get(list_sounds).post(upload_sound), get(list_sounds).post(upload_sound),
) )
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route( .route(
"/guilds/{guild_id}/sounds/{sound_id}", "/guilds/{guild_id}/sounds/{sound_id}",
post(delete_sound_post).delete(delete_sound), post(delete_sound_post).delete(delete_sound),
@ -64,7 +63,6 @@ pub fn routes() -> Router<AppState> {
"/channels/{channel_id}/attachments", "/channels/{channel_id}/attachments",
post(upload_channel_attachment), post(upload_channel_attachment),
) )
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route("/channels/{channel_id}/voice/ws", get(voice_ws)) .route("/channels/{channel_id}/voice/ws", get(voice_ws))
.route("/ws", get(chat_ws)) .route("/ws", get(chat_ws))
} }
@ -92,7 +90,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.session_cookie_secure) auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure)
.parse() .parse()
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?, .map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
); );
@ -158,10 +156,7 @@ 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::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.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
@ -228,51 +223,69 @@ 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 session_id = auth::new_session_id(); let (access_token, refresh_token) =
db::create_session( auth::new_jwt_tokens(user.id, &state.settings.session_secret)
&state.db, .map_err(|e| ApiError::internal(&e.to_string()))?;
&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.session_cookie_secure) auth::clear_oauth_state_cookie(state.settings.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((headers, Redirect::to("/"))) Ok((
headers,
Redirect::to(&format!(
"/?token={}&refresh_token={}",
access_token, refresh_token
)),
))
} }
async fn auth_logout( #[derive(Deserialize)]
State(state): State<AppState>, struct AuthRefreshBody {
user: AuthUser, refresh_token: String,
) -> Result<impl IntoResponse, ApiError> { }
db::revoke_session(&state.db, &user.session_id)
.await
.map_err(|e| ApiError::internal(&format!("failed to revoke session: {e}")))?;
let mut headers = HeaderMap::new(); #[derive(Serialize)]
headers.insert( struct AuthRefreshResponse {
header::SET_COOKIE, access_token: String,
auth::clear_session_cookie(state.settings.session_cookie_secure) refresh_token: String,
.parse() }
.map_err(|_| ApiError::internal("failed to clear session cookie"))?,
); async fn auth_refresh(
Ok((headers, StatusCode::NO_CONTENT)) State(state): State<AppState>,
Json(body): Json<AuthRefreshBody>,
) -> Result<impl IntoResponse, ApiError> {
let user_id = auth::verify_session(
&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
.map_err(|_| ApiError::internal("user verification failed"))?;
if !exists {
return Err(ApiError::unauthorized("user not found"));
}
let (access_token, refresh_token) =
auth::new_jwt_tokens(user_id, &state.settings.session_secret)
.map_err(|e| ApiError::internal(&e.to_string()))?;
Ok(Json(AuthRefreshResponse {
access_token,
refresh_token,
}))
}
async fn auth_logout() -> Result<impl IntoResponse, ApiError> {
Ok(StatusCode::NO_CONTENT)
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -333,12 +346,9 @@ 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 visible_user_ids = db::list_visible_user_ids(&state.db, user.id) let users = state.chat.get_online_users().await;
.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))
} }
@ -683,16 +693,11 @@ async fn upload_channel_attachment(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser, user: AuthUser,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
headers: HeaderMap, multipart: Multipart,
body: Bytes,
) -> 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?;
let uploaded = upload_media_from_request( let uploaded =
&state, upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id))
&headers,
body,
channel_object_key_prefix(channel_id),
)
.await?; .await?;
let message = db::create_message_with_attachment( let message = db::create_message_with_attachment(
@ -726,8 +731,7 @@ async fn upload_dm_attachment(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser, user: AuthUser,
Path(other_user_id): Path<Uuid>, Path(other_user_id): Path<Uuid>,
headers: HeaderMap, multipart: Multipart,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
if other_user_id == user.id { if other_user_id == user.id {
return Err(ApiError::bad_request("cannot send dm to yourself")); return Err(ApiError::bad_request("cannot send dm to yourself"));
@ -743,10 +747,9 @@ async fn upload_dm_attachment(
}); });
} }
let uploaded = upload_media_from_request( let uploaded = upload_media_from_multipart(
&state, &state,
&headers, multipart,
body,
dm_object_key_prefix(user.id, other_user_id), dm_object_key_prefix(user.id, other_user_id),
) )
.await?; .await?;
@ -770,10 +773,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?;
@ -795,9 +798,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)))
} }
@ -861,46 +864,29 @@ async fn upload_sound(
_ => return Err(ApiError::bad_request("missing fields")), _ => return Err(ApiError::bad_request("missing fields")),
}; };
let validated = validate_sound_upload(&file_name, &file_data) let extension = std::path::Path::new(&file_name)
.map_err(|e| ApiError::bad_request(&e.to_string()))?; .extension()
.and_then(|e| e.to_str())
.unwrap_or("mp3");
let storage = state let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension);
.media let upload_dir = std::path::Path::new("static/uploads/soundboard");
.as_ref() tokio::fs::create_dir_all(upload_dir)
.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 sound = db::create_sound( let file_path = upload_dir.join(&safe_file_name);
&state.db, tokio::fs::write(&file_path, file_data)
guild_id, .await
user.id, .map_err(|e| ApiError::internal(&e.to_string()))?;
name.trim(),
icon.trim(), let web_path = format!("/static/uploads/soundboard/{}", safe_file_name);
&object_key,
&media_url, let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?;
&validated.mime_type,
validated.size_bytes,
)
.await?;
Ok(Json(sound)) Ok(Json(sound))
} }
const MAX_MEDIA_UPLOAD_BYTES: usize = 50 * 1024 * 1024; const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
struct UploadedMedia { struct UploadedMedia {
object_key: String, object_key: String,
@ -910,67 +896,64 @@ struct UploadedMedia {
original_filename: String, original_filename: String,
} }
#[derive(Debug)] async fn upload_media_from_multipart(
struct ValidatedUpload {
mime_type: String,
inline: bool,
size_bytes: i64,
}
async fn upload_media_from_request(
state: &AppState, state: &AppState,
headers: &HeaderMap, mut multipart: Multipart,
body: Bytes,
object_key_prefix: String, object_key_prefix: String,
) -> Result<UploadedMedia, ApiError> { ) -> Result<UploadedMedia, ApiError> {
let storage = state let storage = state
.media .media
.as_ref() .as_ref()
.ok_or_else(|| ApiError::service_unavailable("media storage is not configured"))?; .ok_or_else(|| ApiError::internal("media storage is not configured"))?;
let original_filename = headers let mut file_name = None;
.get("x-file-name") let mut file_data = None;
.and_then(|v| v.to_str().ok()) let mut mime_type = None;
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToString::to_string)
.ok_or_else(|| ApiError::bad_request("missing x-file-name header"))?;
let raw_mime_type = headers while let Some(field) = multipart
.get(header::CONTENT_TYPE) .next_field()
.and_then(|v| v.to_str().ok()) .await
.map(str::trim) .map_err(|e| ApiError::bad_request(&e.to_string()))?
.filter(|v| !v.is_empty()) {
if field.name().unwrap_or_default() != "file" {
continue;
}
file_name = Some(field.file_name().unwrap_or("upload.bin").to_string());
mime_type = Some(
field
.content_type()
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
.to_string(); .to_string(),
);
if body.is_empty() { let bytes = field
return Err(ApiError::bad_request("missing file upload")); .bytes()
} .await
if body.len() > MAX_MEDIA_UPLOAD_BYTES {
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()))?; .map_err(|e| ApiError::bad_request(&e.to_string()))?;
if bytes.len() > MAX_MEDIA_UPLOAD_BYTES {
return Err(ApiError::bad_request("file exceeds 25MB upload limit"));
}
file_data = Some(bytes.to_vec());
break;
}
let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) {
(Some(name), Some(mime), Some(data)) => (name, mime, data),
_ => return Err(ApiError::bad_request("missing file upload")),
};
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 = file_data.len() as i64;
let media_url = storage let media_url = storage
.upload_object( .upload_object(&object_key, file_data, &mime_type, &original_filename)
&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: validated.mime_type, mime_type,
size_bytes, size_bytes,
original_filename, original_filename,
}) })
@ -1034,7 +1017,7 @@ fn sanitize_file_name(file_name: &str) -> String {
}) })
.collect(); .collect();
let trimmed = sanitized.trim_matches(|ch| ch == '_' || ch == '.').trim(); let trimmed = sanitized.trim_matches('_').trim();
if trimmed.is_empty() { if trimmed.is_empty() {
"upload.bin".to_string() "upload.bin".to_string()
} else { } else {
@ -1042,141 +1025,6 @@ 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!(
@ -1241,15 +1089,10 @@ async fn delete_sound(
}); });
} }
let object_key = db::get_sound_object_key(&state.db, sound_id) // Delete file from disk
.await let relative_path = sound.file_path.trim_start_matches('/');
.map_err(|e| ApiError::internal(&format!("failed to load sound storage key: {e}")))?; if let Err(e) = tokio::fs::remove_file(relative_path).await {
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)
@ -1293,100 +1136,3 @@ 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");
}
}

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,6 @@ 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()
@ -56,8 +55,7 @@ impl MediaStorage {
.body(ByteStream::from(bytes)) .body(ByteStream::from(bytes))
.content_type(content_type) .content_type(content_type)
.content_disposition(format!( .content_disposition(format!(
"{}; filename=\"{}\"", "inline; filename=\"{}\"",
if inline { "inline" } else { "attachment" },
sanitize_header_value(original_filename) sanitize_header_value(original_filename)
)) ))
.send() .send()
@ -66,17 +64,6 @@ 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 {

View file

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

View file

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

View file

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

View file

@ -6,9 +6,6 @@ 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;
@ -22,9 +19,6 @@ 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),
] ]
} }
} }

View file

@ -30,6 +30,15 @@ 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,
@ -93,9 +102,7 @@ pub struct SoundboardSound {
pub guild_id: Uuid, pub guild_id: Uuid,
pub name: String, pub name: String,
pub icon: String, pub icon: String,
pub media_url: String, pub file_path: 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,
} }

View file

@ -10,7 +10,7 @@ use crate::{AppState, db};
#[derive(Default)] #[derive(Default)]
pub struct VoiceHub { pub struct VoiceHub {
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, HashMap<Uuid, ClientHandle>>>>, rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -72,7 +72,7 @@ enum ServerEvent {
}, },
PlaySound { PlaySound {
user_id: Uuid, user_id: Uuid,
media_url: String, sound_url: String,
}, },
} }
@ -97,20 +97,10 @@ enum ClientEvent {
is_muted: bool, is_muted: bool,
}, },
PlaySound { PlaySound {
sound_id: Uuid, sound_url: String,
}, },
} }
#[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;
@ -119,7 +109,14 @@ impl VoiceHub {
}; };
room.iter() room.iter()
.filter_map(|(user_id, connections)| aggregate_participant(Some(connections), *user_id)) .map(|(user_id, handle)| VoiceParticipant {
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()
} }
@ -127,23 +124,28 @@ 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>, VoiceStateChange) { ) -> Vec<VoiceParticipant> {
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()
.filter_map(|(peer_id, connections)| aggregate_participant(Some(connections), *peer_id)) .map(|(peer_id, peer)| VoiceParticipant {
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<_>>();
let previous = aggregate_participant(room.get(&user_id), user_id); room.insert(
room.entry(user_id).or_default().insert( user_id,
connection_id,
ClientHandle { ClientHandle {
display_name, display_name: display_name.clone(),
is_sharing_video: false, is_sharing_video: false,
is_sharing_screen: false, is_sharing_screen: false,
is_speaking: false, is_speaking: false,
@ -151,31 +153,34 @@ impl VoiceHub {
tx, tx,
}, },
); );
let current = aggregate_participant(room.get(&user_id), user_id);
(peers, diff_voice_state(previous, current)) for (peer_id, peer) in room.iter() {
if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::PeerJoined {
user_id,
display_name: display_name.clone(),
});
}
} }
async fn leave(&self, room_id: Uuid, user_id: Uuid, connection_id: Uuid) -> VoiceStateChange { peers
}
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 VoiceStateChange::default(); return;
}; };
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(
@ -191,10 +196,7 @@ impl VoiceHub {
return; return;
}; };
if let Some(target) = room if let Some(target) = room.get(&to_user_id) {
.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,
@ -203,185 +205,99 @@ impl VoiceHub {
} }
} }
async fn set_video_status( pub async fn set_video_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_video: bool) {
&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 VoiceStateChange::default();
};
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;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
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 Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
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;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
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 Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
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;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
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 Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
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;
}
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, media_url: String) {
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
return; return;
}; };
for (peer_id, connections) in room.iter() { if let Some(handle) = room.get_mut(&user_id) {
if *peer_id == user_id { handle.is_sharing_video = is_sharing_video;
continue;
} for (peer_id, peer) in room.iter() {
for peer in connections.values() { if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::PlaySound { let _ = peer.tx.send(ServerEvent::VideoStatusChanged {
user_id, user_id,
media_url: media_url.clone(), is_sharing_video,
}); });
} }
} }
} }
async fn emit_change(&self, room_id: Uuid, user_id: Uuid, change: VoiceStateChange) {
if is_voice_state_change_empty(&change) {
return;
} }
pub async fn set_screen_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_screen: bool) {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
};
if let Some(handle) = room.get_mut(&user_id) {
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,
});
}
}
}
}
pub async fn set_speaking_status(&self, room_id: Uuid, user_id: Uuid, is_speaking: bool) {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
};
if let Some(handle) = room.get_mut(&user_id) {
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,
});
}
}
}
}
pub async fn set_mute_status(&self, room_id: Uuid, user_id: Uuid, is_muted: bool) {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
};
if let Some(handle) = room.get_mut(&user_id) {
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 });
}
}
}
}
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_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;
}; };
if let Some(participant) = change.joined { for (peer_id, peer) in room.iter() {
broadcast_voice_event( if *peer_id == user_id {
room, continue;
user_id,
ServerEvent::PeerJoined {
user_id: participant.user_id,
display_name: participant.display_name,
},
);
} }
if let Some(left_user_id) = change.left_user_id { let _ = peer.tx.send(ServerEvent::PlaySound {
broadcast_voice_event(
room,
user_id, user_id,
ServerEvent::PeerLeft { sound_url: sound_url.clone(),
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,
},
);
} }
} }
} }
@ -390,26 +306,15 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
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, change) = state let peers = state
.voice .voice
.join( .join(room_id, user_id, user.display_name.clone(), tx.clone())
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 {
@ -438,42 +343,31 @@ 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 }) => {
let change = state
.voice
.set_video_status(room_id, user_id, connection_id, is_sharing_video)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
let change = state
.voice
.set_screen_status(room_id, user_id, connection_id, is_sharing_screen)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
let change = state
.voice
.set_speaking_status(room_id, user_id, connection_id, is_speaking)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
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
.play_sound(room_id, user_id, sound.media_url) .set_video_status(room_id, user_id, is_sharing_video)
.await; .await;
} }
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
state
.voice
.set_screen_status(room_id, user_id, is_sharing_screen)
.await;
}
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
state
.voice
.set_speaking_status(room_id, user_id, is_speaking)
.await;
}
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
state
.voice
.set_mute_status(room_id, user_id, is_muted)
.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 {
@ -488,183 +382,5 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
} }
send_task.abort(); send_task.abort();
let change = state.voice.leave(room_id, user_id, connection_id).await; state.voice.leave(room_id, user_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);
}
} }

View file

@ -6,9 +6,12 @@
<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="/static/vendor/lucide.min.js"></script> <script src="https://unpkg.com/lucide@latest"></script>
</head> </head>
<body> <body>
@ -117,10 +120,6 @@
<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>
@ -271,16 +270,6 @@
</div> </div>
</div> </div>
<div id="upload-limit-modal" class="modal-container hidden">
<div class="modal">
<h2>Upload Too Large</h2>
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
<div class="modal-footer">
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
</div>
</div>
</div>
<script type="module" src="/static/app.js?v=20260227-shared-core-1"></script> <script type="module" src="/static/app.js?v=20260227-shared-core-1"></script>
</body> </body>

View file

@ -1,5 +1,4 @@
const state = { const state = {
sessionActive: false,
me: null, me: null,
guilds: [], guilds: [],
channels: [], channels: [],
@ -29,7 +28,6 @@ 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,
@ -40,8 +38,15 @@ const state = {
userVolumes: new Map(), // userId -> volume (0.0 to 2.0) userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
}; };
// --- Storage --- // --- Desktop Backend Configuration ---
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);
@ -49,15 +54,103 @@ 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";
return `${proto}://${location.host}${path}`; urlStr = `${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;
} }
@ -137,9 +230,6 @@ const el = {
soundFile: document.getElementById('sound-file'), soundFile: document.getElementById('sound-file'),
soundModalCancel: document.getElementById('sound-modal-cancel'), soundModalCancel: document.getElementById('sound-modal-cancel'),
soundSubmitBtn: document.getElementById('sound-submit-btn'), soundSubmitBtn: document.getElementById('sound-submit-btn'),
uploadLimitModal: document.getElementById('upload-limit-modal'),
uploadLimitMessage: document.getElementById('upload-limit-message'),
uploadLimitOk: document.getElementById('upload-limit-ok'),
// Mobile // Mobile
mobileMenuBtn: document.getElementById("mobile-menu-btn"), mobileMenuBtn: document.getElementById("mobile-menu-btn"),
@ -155,83 +245,57 @@ 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)) {
headers["content-type"] = "application/json"; const token = storageGet("chattz_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
} }
const res = await fetch(path, { let res = await fetch(fullUrl, {
...options, ...options,
headers, headers,
credentials: "same-origin",
}); });
if (res.status === 401) { // Handle 401 Unauthorized via Refresh Token
await showLoggedOutState("Session expired, please log in again"); if (res.status === 401 && !path.includes('/auth/refresh')) {
const err = new Error("Session expired, please log in again"); const refreshToken = storageGet("chattz_refresh_token");
err.isAuthError = true; if (refreshToken) {
throw err; try {
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) {
@ -269,18 +333,16 @@ function escapeHtml(s) {
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
const GIF_URL_REGEX = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi; function formatMessageBody(body) {
const escaped = escapeHtml(body);
function normalizeMediaUrl(raw) { // Detect GIF URLs (simple regex for demo)
try { const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
const url = new URL(raw, location.origin); if (urlRegex.test(body)) {
if (url.protocol !== "http:" && url.protocol !== "https:") { return escaped.replace(urlRegex, (url) => {
return null; return `<div class="msg-gif"><img src="${url}" loading="lazy" /></div>`;
} });
return url.href;
} catch {
return null;
} }
return escaped;
} }
function formatBytes(size) { function formatBytes(size) {
@ -295,137 +357,29 @@ function formatBytes(size) {
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
} }
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024; function renderAttachments(attachments = []) {
if (!attachments.length) return "";
function buildMessageBody(body) {
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 = attachment.original_filename || "file"; const fileName = escapeHtml(attachment.original_filename || "file");
const size = formatBytes(attachment.size_bytes); const size = formatBytes(attachment.size_bytes);
if (mime.startsWith("image/")) { if (mime.startsWith("image/")) {
const link = document.createElement("a"); return `<a class="msg-attachment msg-attachment-image" href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${fileName}" loading="lazy" /></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/")) {
const wrapper = document.createElement("div"); return `<div class="msg-attachment msg-attachment-video"><video controls preload="metadata" src="${url}"></video></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/")) {
const wrapper = document.createElement("div"); 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>`;
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;
} }
const link = document.createElement("a"); 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>`;
link.className = "msg-attachment msg-attachment-file"; }).join("");
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) {
const selectedSize = formatBytes(file?.size || 0);
if (el.uploadLimitModal && el.uploadLimitMessage) {
el.uploadLimitMessage.textContent = `Uploads are limited to 50 MB per file. Selected file size: ${selectedSize}.`;
el.uploadLimitModal.classList.remove("hidden");
return;
}
alert(`Uploads are limited to 50 MB per file.\nSelected file size: ${selectedSize}.`);
} }
function formatDate(isoString) { function formatDate(isoString) {
@ -577,103 +531,50 @@ 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 voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`; pRow.className = `channel-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";
const topRow = document.createElement("div"); let sliderHtml = '';
topRow.style.display = "flex"; if (p.user_id !== state.me.id) {
topRow.style.alignItems = "center"; const vol = state.userVolumes.get(p.user_id) ?? 1.0;
topRow.style.gap = "8px"; const isVisible = state.voice.visibleVolumeSliders.has(p.user_id);
topRow.style.width = "100%"; sliderHtml = `
<div class="user-volume-control ${isVisible ? 'show-volume' : ''}" data-user-id="${p.user_id}">
const avatar = document.createElement("div"); <i data-lucide="volume-2" style="width: 12px; height: 12px; opacity: 0.6;"></i>
avatar.className = "avatar"; <input type="range" min="0" max="2" step="0.1" value="${vol}" class="volume-slider">
avatar.style.width = "20px"; <span class="vol-pct">${Math.round(vol * 100)}%</span>
avatar.style.height = "20px"; </div>
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);
});
} }
if (p.is_muted) { pRow.innerHTML = `
const muteIcon = document.createElement("i"); <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
muteIcon.setAttribute("data-lucide", "mic-off"); <div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div>
muteIcon.className = "voice-muted-icon"; <span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span>
topRow.appendChild(muteIcon); ${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}
} </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);
volumePct.textContent = `${Math.round(val * 100)}%`; pRow.querySelector('.vol-pct').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 && state.voice.audioContext) { if (gainNode) {
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
volumeControl.addEventListener('click', (e) => e.stopPropagation()); slider.addEventListener('click', (e) => e.stopPropagation());
volumeControl.addEventListener('mousedown', (e) => e.stopPropagation());
pRow.appendChild(volumeControl);
} }
const toggleVolumeSlider = (e) => { const toggleVolumeSlider = (e) => {
if (!isRemoteParticipant) return; if (p.user_id === state.me.id) 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)) {
@ -687,9 +588,6 @@ 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);
}); });
@ -750,40 +648,25 @@ 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 content = document.createElement("div"); const formattedBody = formatMessageBody(m.body || "");
content.className = "msg-content"; const attachmentsHtml = renderAttachments(m.attachments);
const bodyHtml = formattedBody ? `<div class="msg-body">${formattedBody}</div>` : "";
const contentHtml = `${bodyHtml}${attachmentsHtml}`;
if (!isGrouped) { if (isGrouped) {
const avatar = document.createElement("div"); row.innerHTML = `<div class="msg-content">${contentHtml}</div>`;
avatar.className = "msg-avatar"; } else {
avatar.textContent = shortName(displayName); row.innerHTML = `
row.appendChild(avatar); <div class="msg-avatar">${shortName(displayName)}</div>
<div class="msg-content">
const header = document.createElement("div"); <div class="msg-header">
header.className = "msg-header"; <span class="msg-author">${escapeHtml(displayName)}</span>
<span class="msg-time">${formatDate(m.created_at)}</span>
const author = document.createElement("span"); </div>
author.className = "msg-author"; ${contentHtml}
author.textContent = displayName; </div>
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;
@ -886,7 +769,8 @@ 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 link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin;
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) {
@ -972,15 +856,13 @@ async function refreshVoicePresence() {
} }
function startVoicePresencePolling() { function startVoicePresencePolling() {
stopVoicePresencePolling(); if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
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);
@ -1023,7 +905,6 @@ 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);
}; };
@ -1040,24 +921,7 @@ 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;
@ -1283,7 +1147,7 @@ async function createLocalVoiceStream() {
sampleRate: 48000, sampleRate: 48000,
echoCancellation: true, echoCancellation: true,
noiseSuppression: false, noiseSuppression: false,
autoGainControl: true, autoGainControl: false,
}, },
video: false, video: false,
}; };
@ -1406,27 +1270,16 @@ 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(compressorNode); sourceNode.connect(gainNode);
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;
@ -1611,7 +1464,6 @@ 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);
@ -1638,7 +1490,7 @@ async function joinVoice() {
}); });
renderChannels(); renderChannels();
} else if (msg.type === "play_sound") { } else if (msg.type === "play_sound") {
const audio = new Audio(msg.media_url); const audio = new Audio(msg.sound_url);
audio.play().catch(console.error); audio.play().catch(console.error);
} }
refreshVoicePresence().catch(() => { }); refreshVoicePresence().catch(() => { });
@ -1813,22 +1665,11 @@ 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));
const icon = document.createElement('div'); item.innerHTML = `
icon.className = 'sound-icon'; <div class="sound-icon">${sound.icon}</div>
icon.textContent = sound.icon; <div class="sound-name">${sound.name}</div>
const name = document.createElement('div'); ${canDelete ? `<button class="sound-delete" title="Delete sound"><i data-lucide="x"></i></button>` : ''}
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')) {
@ -1838,7 +1679,7 @@ function renderSounds(sounds) {
} }
return; return;
} }
playRemoteSound(sound.id, sound.media_url); playRemoteSound(sound.file_path);
}; };
el.soundboardGrid.appendChild(item); el.soundboardGrid.appendChild(item);
@ -1849,19 +1690,27 @@ function renderSounds(sounds) {
async function deleteSound(soundId) { async function deleteSound(soundId) {
if (!state.selectedGuildId) return; if (!state.selectedGuildId) return;
try { try {
await api(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { method: 'DELETE' }); const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`;
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(soundId, mediaUrl) { function playRemoteSound(url) {
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_id: soundId })); state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl }));
} }
// Also play locally immediately // Also play locally immediately
const audio = new Audio(mediaUrl); const audio = new Audio(fullUrl);
audio.play().catch(console.error); audio.play().catch(console.error);
} }
@ -1926,23 +1775,41 @@ 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 = () => {
el.status.textContent = ""; const loginPath = "/auth/login";
location.href = "/auth/login"; location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath;
}; };
el.logoutBtn.onclick = async () => { el.logoutBtn.onclick = async () => {
try { await leaveVoice();
await api("/auth/logout", { method: "POST" }); await storageRemoveCritical("chattz_token");
} catch (e) { await storageRemoveCritical("chattz_refresh_token");
if (!e?.isAuthError) { try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
console.warn("logout request failed", e); location.reload();
}
}
await showLoggedOutState("Signed out.");
}; };
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); }; el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
@ -2088,11 +1955,8 @@ async function init() {
el.soundSubmitBtn.disabled = true; el.soundSubmitBtn.disabled = true;
el.soundSubmitBtn.textContent = 'Uploading...'; el.soundSubmitBtn.textContent = 'Uploading...';
try { try {
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, { const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`;
method: 'POST', const response = await fetch(url, { method: 'POST', body: formData });
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();
@ -2103,17 +1967,6 @@ async function init() {
} }
}; };
if (el.uploadLimitOk && el.uploadLimitModal) {
el.uploadLimitOk.onclick = () => {
el.uploadLimitModal.classList.add("hidden");
};
el.uploadLimitModal.onclick = (e) => {
if (e.target === el.uploadLimitModal) {
el.uploadLimitModal.classList.add("hidden");
}
};
}
// 5. GIF Picker // 5. GIF Picker
const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0";
const TENOR_CLIENT_KEY = "pavel-discord"; const TENOR_CLIENT_KEY = "pavel-discord";
@ -2184,10 +2037,8 @@ async function init() {
} }
async function uploadMediaFile(file) { async function uploadMediaFile(file) {
if (file.size > MAX_MEDIA_UPLOAD_BYTES) { const token = storageGet("chattz_token");
showMediaUploadLimitError(file); if (!token) throw new Error("You need to log in again.");
return;
}
const path = state.selectedTextChannelId const path = state.selectedTextChannelId
? `/channels/${state.selectedTextChannelId}/attachments` ? `/channels/${state.selectedTextChannelId}/attachments`
@ -2196,18 +2047,20 @@ async function init() {
: null; : null;
if (!path) throw new Error("Select a chat first."); if (!path) throw new Error("Select a chat first.");
const formData = new FormData();
formData.append("file", file);
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(path, { const response = await fetch(fullUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": file.type || "application/octet-stream", Authorization: `Bearer ${token}`,
"X-File-Name": file.name,
}, },
body: file, body: formData,
credentials: "same-origin",
}); });
if (!response.ok) { if (!response.ok) {
@ -2234,12 +2087,10 @@ 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");
@ -2264,8 +2115,7 @@ async function init() {
try { try {
const online = await api("/presence"); const online = await api("/presence");
state.onlineUsers = new Set(online.filter((entry) => entry.online).map((entry) => entry.user_id)); state.onlineUsers = new Set(online);
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) {
@ -2299,10 +2149,9 @@ async function init() {
lucide.createIcons(); lucide.createIcons();
} catch (err) { } catch (err) {
if (!err?.isAuthError) {
console.error("init failed", err); console.error("init failed", err);
await showLoggedOutState("Unable to load the app."); el.authScreen.classList.remove("hidden");
} el.main.classList.add("hidden");
} }
} }
@ -2402,4 +2251,5 @@ function initUpdater() {
}); });
} }
init(); // Config fetcher will trigger init()
initializeConfig();

View file

@ -1478,13 +1478,6 @@ select {
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
.modal-copy {
margin: 0;
text-align: center;
color: var(--text-normal);
line-height: 1.5;
}
.form-item { .form-item {
margin-bottom: 20px; margin-bottom: 20px;
} }

File diff suppressed because one or more lines are too long