Compare commits

...

58 commits

Author SHA1 Message Date
39eb7c77ad fix: volume
All checks were successful
/ upload (release) Successful in 6m5s
2026-03-27 22:41:54 +01:00
9d7f658fc0 periodic update check
All checks were successful
/ upload (release) Successful in 3m39s
2026-02-28 15:15:42 +01:00
c9592a9cb0 fix updater
All checks were successful
/ upload (release) Successful in 3m41s
2026-02-28 15:05:09 +01:00
76097f3294 fix crash
All checks were successful
/ upload (release) Successful in 3m45s
2026-02-28 14:46:38 +01:00
6dbd5e77c4 fix desktop backend url
All checks were successful
/ upload (release) Successful in 3m40s
2026-02-28 13:44:57 +01:00
afbaa8f7d1 fix volume slider 2026-02-28 13:43:03 +01:00
3acd082fb0 crazy refactor
All checks were successful
/ upload (release) Successful in 4m59s
2026-02-27 20:34:33 +01:00
24975c2e0d fix uploads
Some checks failed
/ upload (release) Has been cancelled
2026-02-27 16:28:32 +01:00
6882b49336 refactor
All checks were successful
/ upload (release) Successful in 3m32s
2026-02-27 15:57:39 +01:00
5c97cc515f fix: uploads 2026-02-27 15:51:03 +01:00
e1dd679c47 feat: media uploads
All checks were successful
/ upload (release) Successful in 5m37s
2026-02-27 15:38:46 +01:00
cfef57d8a1 skip version commit 2026-02-27 15:00:17 +01:00
003391e60e chat scroll 2026-02-27 14:59:27 +01:00
Forgejo Actions
80be74782c chore: bump version to 0.0.50 [skip ci] 2026-02-27 13:37:20 +00:00
ec3b29f4d9 missing rpm
All checks were successful
/ upload (release) Successful in 3m25s
2026-02-27 14:35:19 +01:00
Forgejo Actions
86a5c4b7c3 chore: bump version to 0.0.49 [skip ci] 2026-02-27 11:37:00 +00:00
ad6eaad5d2 updater stuff
All checks were successful
/ upload (release) Successful in 1m41s
2026-02-27 12:36:20 +01:00
208ab8042e checkpoint 2026-02-27 12:36:20 +01:00
3735162f41 refactor 2026-02-27 12:36:20 +01:00
Forgejo Actions
0b5fc9914c chore: bump version to 0.0.48 [skip ci] 2026-02-27 00:02:53 +00:00
88e0a0bbfa fix
All checks were successful
/ upload (release) Successful in 1m41s
2026-02-27 01:01:53 +01:00
Forgejo Actions
59e4ed9868 chore: bump version to 0.0.47 [skip ci] 2026-02-26 23:34:19 +00:00
51a18472eb test
All checks were successful
/ upload (release) Successful in 3m21s
2026-02-27 00:33:47 +01:00
Forgejo Actions
1762368e2d chore: bump version to 0.0.46 [skip ci] 2026-02-26 22:58:46 +00:00
832de038ca auto update desktop app
All checks were successful
/ upload (release) Successful in 3m23s
2026-02-26 23:58:11 +01:00
8847f3fcf6 refresh browser on connection loss 2026-02-26 23:43:43 +01:00
619f50d794 fix slider 2026-02-26 23:36:27 +01:00
77a9f5ee88 fix slider
All checks were successful
/ upload (release) Successful in 3m22s
2026-02-26 23:17:03 +01:00
168ae4150e volume
All checks were successful
/ upload (release) Successful in 3m21s
2026-02-26 23:06:40 +01:00
af46da77a7 indicators 2026-02-26 22:35:27 +01:00
4358d49b48 fix
All checks were successful
/ upload (release) Successful in 3m21s
2026-02-26 00:35:35 +01:00
ac0570b82c yes
All checks were successful
/ upload (release) Successful in 3m13s
2026-02-26 00:20:51 +01:00
270785a21f fiiix
All checks were successful
/ upload (release) Successful in 3m16s
2026-02-26 00:13:15 +01:00
6f9c24ac60 a
All checks were successful
/ upload (release) Successful in 3m18s
2026-02-25 19:50:10 +01:00
539e2a4ab8 fix 2026-02-25 19:50:05 +01:00
b7416c7c2b fix
All checks were successful
/ upload (release) Successful in 3m17s
2026-02-25 19:41:33 +01:00
72a10ad32b activity
All checks were successful
/ upload (release) Successful in 3m15s
2026-02-25 18:46:24 +01:00
4d1bea502a dfn
All checks were successful
/ upload (release) Successful in 3m11s
2026-02-25 18:42:28 +01:00
e954433289 dnn3
All checks were successful
/ upload (release) Successful in 3m17s
2026-02-25 18:29:19 +01:00
babbb33f69 g
All checks were successful
/ upload (release) Successful in 3m24s
2026-02-25 17:17:39 +01:00
1a6d7bf219 jj
Some checks failed
/ upload (release) Failing after 2m12s
2026-02-25 16:56:33 +01:00
1bfb5f7d0d a
Some checks failed
/ upload (release) Failing after 2m4s
2026-02-25 16:52:40 +01:00
a286eca3f0 l
Some checks failed
/ upload (release) Failing after 20s
2026-02-25 16:46:50 +01:00
551c8ef145 p
Some checks failed
/ upload (release) Failing after 14s
2026-02-25 16:45:05 +01:00
3eb1861589 inst
Some checks failed
/ upload (release) Has been cancelled
2026-02-25 16:37:42 +01:00
6dc6cecfcc yep 2026-02-25 16:29:36 +01:00
63d8aa9fe3 a 2026-02-25 14:57:46 +01:00
a4abbf7844 f 2026-02-25 01:08:13 +01:00
0273eab753 a
All checks were successful
/ upload (release) Successful in 22s
2026-02-25 01:00:07 +01:00
bcac7fc375 h
All checks were successful
/ upload (release) Successful in 22s
2026-02-25 00:38:34 +01:00
be13d4d45a sadf
All checks were successful
/ upload (release) Successful in 21s
2026-02-25 00:29:37 +01:00
92cba49b0d g
All checks were successful
/ upload (release) Successful in 22s
2026-02-24 23:39:27 +01:00
d3a109a0cc sdfg
All checks were successful
/ upload (release) Successful in 20s
2026-02-24 23:18:46 +01:00
58a459a1dc h 2026-02-24 22:37:42 +01:00
a5af009ba8 test
All checks were successful
/ upload (release) Successful in 22s
2026-02-24 22:28:02 +01:00
1ec1ba0028 fix
All checks were successful
/ upload (release) Successful in 21s
2026-02-24 21:58:25 +01:00
b3242b84ff fix
All checks were successful
/ upload (release) Successful in 22s
2026-02-24 21:41:32 +01:00
d703501b78 test
All checks were successful
/ upload (release) Successful in 25s
2026-02-24 21:37:18 +01:00
49 changed files with 14541 additions and 1805 deletions

View file

@ -1,5 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
PORT=3000
APP_BASE_URL=http://localhost:3000
# Authentik OIDC app values
OIDC_CLIENT_ID=replace-me
@ -16,6 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr
TURN_USERNAME=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
R2_ACCOUNT_ID=replace-me
R2_ACCESS_KEY_ID=replace-me
R2_SECRET_ACCESS_KEY=replace-me
R2_BUCKET=chattz-media
MEDIA_BASE_URL=https://media.example.com

View file

@ -11,6 +11,51 @@ jobs:
with:
node-version: 24
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Update version from tag
run: |
# Extract version from tag (e.g. v1.2.3 -> 1.2.3)
VERSION=${GITHUB_REF_NAME#v}
echo "Bumping version to $VERSION"
npm version $VERSION --no-git-tag-version
- run: npm ci
- run: npm run dist:linux
- name: Build Windows installer with 32-bit Wine prefix
run: |
export WINEARCH=win32
export WINEPREFIX="$HOME/.wine32"
wineboot -u
npm run dist:win
- name: Copy installers into static folder
run: |
mkdir -p static/installers
find static/installers -mindepth 1 -maxdepth 1 -type f -delete
copy_first() {
pattern="$1"
out="$2"
file="$(find dist -maxdepth 1 -type f -name "$pattern" | head -n 1)"
if [ -n "$file" ]; then
cp "$file" "static/installers/$out"
fi
}
# Copy versioned artifacts for electron-updater
cp dist/*.AppImage static/installers/ || true
cp dist/*.exe static/installers/ || true
cp dist/*.rpm static/installers/ || true
cp dist/*.deb static/installers/ || true
cp dist/*.msi static/installers/ || true
# Maintain generic names for stable website links
copy_first '*.rpm' 'chattz-linux.rpm'
copy_first '*.deb' 'chattz-linux.deb'
copy_first '*.AppImage' 'chattz-linux.AppImage'
copy_first '*.exe' 'chattz-windows.exe'
copy_first '*.msi' 'chattz-windows.msi'
# Metadata files for electron-updater
copy_first 'latest-linux.yml' 'latest-linux.yml'
copy_first 'latest.yml' 'latest.yml'
- name: Cache Cargo registry
uses: actions/cache@v4
with:

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
/target
.env
node_modules
dist

866
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,17 +5,20 @@ edition = "2024"
[dependencies]
anyhow = "1"
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] }
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] }
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
futures-util = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "fs"] }
tracing = "0.1"
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
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
- Signed session cookie auth
- HttpOnly session cookie auth
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
- Guild invite codes (create + join)
- Direct messages (DM) between users
@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`:
- `TURN_USERNAME`
- `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:
```bash
@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`.
## Authentik setup notes
Create an Authentik OAuth2/OIDC provider + application and set:
- Redirect URI: `http://localhost:3000/auth/callback`
- Redirect URI: `${APP_BASE_URL}/auth/callback`
- Scopes including at least: `openid profile email`
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug.
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
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
@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
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.
User uploads are served from the configured media origin, not from `/static`.
Mic filter modes in the UI:
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

1
desktop/app.js Normal file
View file

@ -0,0 +1 @@
import "../static/shared/app-core.js";

282
desktop/index.html Normal file
View file

@ -0,0 +1,282 @@
<!-- Generated from shared-html/index.template.html. Do not edit directly. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Chattz</title>
<link rel="stylesheet" href="../static/styles.css" />
<!-- Lucide Icons -->
<script src="../static/vendor/lucide.min.js"></script>
</head>
<body>
<div class="auth-screen" id="auth-screen">
<div class="auth-card">
<div class="brand-large">C</div>
<h1>Chattz</h1>
<p>Sign in to open your servers.</p>
<button id="login-btn">Login with Authentik</button>
<p id="status" class="status-line"></p>
</div>
</div>
<div class="shell hidden" id="main">
<aside class="server-rail">
<div class="brand" title="Home">
<i data-lucide="message-square"></i>
</div>
<div class="separator"></div>
<div id="guild-list" class="guild-list"></div>
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
<i data-lucide="plus"></i>
</button>
</aside>
<aside class="channel-sidebar">
<header class="sidebar-header clickable" id="guild-header">
<h2 id="guild-title">No server selected</h2>
<div class="sidebar-header-actions">
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
<i data-lucide="link-2"></i>
</button>
<i data-lucide="chevron-down"></i>
</div>
</header>
<div class="sidebar-scroll">
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Text Channels</span>
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
<i data-lucide="plus"></i>
</button>
</div>
<div id="channel-list" class="channel-list"></div>
</section>
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Voice Channels</span>
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
<i data-lucide="plus"></i>
</button>
</div>
<div id="voice-channel-list" class="channel-list"></div>
</section>
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Direct Messages</span>
</div>
<div id="dm-list" class="channel-list"></div>
</section>
</div>
<div class="sidebar-footer">
<div id="voice-connection" class="voice-connection hidden">
<div class="vc-info">
<i data-lucide="signal-high" class="vc-icon"></i>
<div class="vc-text">
<span class="vc-status">Voice Connected</span>
<span id="vc-channel-name" class="vc-name">General</span>
</div>
<div class="vc-actions">
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
</div>
</div>
<div id="soundboard" class="soundboard-container hidden">
<div class="soundboard-header">
<span>Sound Board</span>
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
</div>
<div id="soundboard-grid" class="soundboard-grid"></div>
</div>
</div>
<div class="user-panel">
<div class="avatar-wrapper">
<div id="user-avatar" class="avatar">U</div>
<div class="status-dot online"></div>
</div>
<div class="user-info">
<div id="user-name" class="display-name">Username</div>
<div class="user-status">Online</div>
</div>
<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="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
</div>
</div>
</div>
</aside>
<main class="chat-pane">
<header class="chat-header">
<button id="mobile-menu-btn" class="mobile-toggle-btn" title="Menu">
<i data-lucide="menu"></i>
</button>
<i data-lucide="hash" class="header-icon"></i>
<h3 id="channel-title">Select a channel</h3>
<div style="flex:1"></div>
<button id="mobile-members-btn" class="mobile-toggle-btn" title="Members">
<i data-lucide="users"></i>
</button>
</header>
<div id="video-grid" class="video-grid hidden"></div>
<div id="message-list" class="message-list"></div>
<div class="chat-input-wrapper">
<form id="message-form" class="message-form">
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
<i data-lucide="paperclip"></i>
</button>
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
<i data-lucide="image"></i>
</button>
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
<button type="submit" class="hidden"></button>
</form>
</div>
</main>
<aside class="utility-sidebar" id="utility-sidebar">
<header class="sidebar-header">
<h2>Members</h2>
</header>
<div class="member-list-wrapper">
<div id="member-list" class="member-list"></div>
</div>
</aside>
</div>
<div id="overlay" class="overlay hidden"></div>
<!-- Modals -->
<div id="gif-picker" class="modal-container hidden">
<div class="modal gif-modal">
<div class="modal-header">
<h2>Select GIF</h2>
<button type="button" class="close-btn" id="gif-picker-close">
<i data-lucide="x"></i>
</button>
</div>
<div class="gif-search-wrapper">
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
<i data-lucide="search" class="search-icon"></i>
</div>
<div id="gif-results" class="gif-results-grid">
<!-- GIF results will be injected here -->
<div class="gif-loading hidden">Loading...</div>
</div>
</div>
</div>
<div id="modal-container" class="modal-container hidden">
<div class="modal">
<h2 id="modal-title">Create Server</h2>
<form id="guild-form" class="modal-form">
<div class="form-item">
<label for="guild-name">SERVER NAME</label>
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
<button type="submit" class="submit-btn">Create</button>
</div>
</form>
</div>
</div>
<div id="channel-modal" class="modal-container hidden">
<div class="modal">
<h2>Create Channel</h2>
<form id="channel-form" class="modal-form">
<div class="form-item">
<label>CHANNEL TYPE</label>
<div class="radio-group">
<label class="radio-item">
<input type="radio" name="channel-kind" value="text" checked>
<div class="radio-box">
<i data-lucide="hash"></i>
<div class="radio-text">
<strong>Text</strong>
<span>Send messages, images, and GIFs.</span>
</div>
</div>
</label>
<label class="radio-item">
<input type="radio" name="channel-kind" value="voice">
<div class="radio-box">
<i data-lucide="volume-2"></i>
<div class="radio-text">
<strong>Voice</strong>
<span>Hang out together with voice and video.</span>
</div>
</div>
</label>
</div>
</div>
<div class="form-item">
<label for="channel-name">CHANNEL NAME</label>
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
<button type="submit" class="submit-btn">Create Channel</button>
</div>
</form>
</div>
</div>
<div id="sound-modal" class="modal-container hidden">
<div class="modal">
<h2>Add Sound</h2>
<form id="sound-form" class="modal-form">
<div class="form-item">
<label for="sound-name">NAME</label>
<input id="sound-name" placeholder="Quack" maxlength="32" required />
</div>
<div class="form-item">
<label for="sound-icon">ICON (Emoji or Initials)</label>
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
</div>
<div class="form-item">
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
<input id="sound-file" type="file" accept="audio/*" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
</div>
</form>
</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>
</body>
</html>

20
desktop/preload.js Normal file
View file

@ -0,0 +1,20 @@
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', {
copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text),
getUpdateState: () => ipcRenderer.invoke('get-update-state'),
checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'),
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
downloadUpdate: () => ipcRenderer.send('download-update'),
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
onUpdateState: (callback) => onIpc('update-state', callback),
onUpdateAvailable: (callback) => onIpc('update-available', callback),
onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback),
onUpdateError: (callback) => onIpc('update-error', callback)
});

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

313
main.js Normal file
View file

@ -0,0 +1,313 @@
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const packageJson = require('./package.json');
const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000;
const updateState = {
status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error
info: null,
error: null,
};
function broadcastUpdateState() {
const wins = BrowserWindow.getAllWindows();
for (const win of wins) {
if (!win.isDestroyed()) {
win.webContents.send('update-state', updateState);
}
}
}
function isNewerVersionAvailable(info) {
const next = info && typeof info.version === 'string' ? info.version : '';
return Boolean(next) && next !== app.getVersion();
}
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') {
if (installInProgress) {
console.log(`Update check skipped (${reason}): install already in progress`);
return;
}
if (updateCheckInProgress) {
console.log(`Update check skipped (${reason}): another check is already in progress`);
return;
}
updateCheckInProgress = true;
updateState.status = 'checking';
updateState.error = null;
try {
const result = await autoUpdater.checkForUpdates();
const info = result && result.updateInfo ? result.updateInfo : null;
if (isNewerVersionAvailable(info)) {
updateState.status = 'available';
updateState.info = info;
updateState.error = null;
} else {
updateState.status = 'not-available';
updateState.info = info;
updateState.error = null;
}
} catch (err) {
updateState.status = 'error';
updateState.error = err && err.message ? err.message : String(err);
console.error(`Update check failed (${reason})`, err);
} finally {
updateCheckInProgress = false;
broadcastUpdateState();
}
}
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() {
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({
width: 1200,
height: 800,
title: "Chattz Desktop",
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
session: sess,
preload: path.join(__dirname, 'desktop', 'preload.js')
}
});
win.setAutoHideMenuBar(true);
win.setMenuBarVisibility(true);
sess.setPermissionCheckHandler((webContents, permission) => {
const origin = webContentsOrigin(webContents);
if (origin !== backendOrigin) return false;
return permission === 'media' || permission === 'clipboard-write';
});
sess.setPermissionRequestHandler((webContents, permission, callback) => {
const origin = webContentsOrigin(webContents);
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
callback(true);
} else {
callback(false);
}
});
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) => {
if (sources && sources.length > 0) {
const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0];
callback({ video: screenSource, audio: 'loopback' });
} else {
callback(null);
}
}).catch(err => {
console.error("Failed to get desktop sources for screen share", err);
callback(null);
});
});
win.loadURL(backendUrl).catch((err) => {
console.error(`Failed to load desktop app: ${err}`);
});
// Ensure renderer receives latest updater state after any (re)load.
// Delay broadcast by 300ms to give the renderer time to register its
// onUpdateState IPC listener before we push state.
win.webContents.on('did-finish-load', () => {
setTimeout(() => {
if (!win.isDestroyed()) broadcastUpdateState();
}, 300);
});
// Uncomment to debug
// win.webContents.openDevTools();
}
app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps resolve codec mismatch behavior
app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues)
app.whenReady().then(() => {
ipcMain.handle('clipboard-write', (event, text) => {
clipboard.writeText(text);
return true;
});
ipcMain.handle('get-update-state', () => ({ ...updateState }));
ipcMain.handle('check-for-updates-now', async () => {
await runUpdateCheck('renderer-direct');
return { ...updateState };
});
createWindow();
// Configure Auto-Updater
autoUpdater.autoDownload = false;
autoUpdater.logger = console;
autoUpdater.on('update-available', (info) => {
updateState.status = 'available';
updateState.info = info;
updateState.error = null;
broadcastUpdateState();
const wins = BrowserWindow.getAllWindows();
if (wins.length > 0) wins[0].webContents.send('update-available', info);
});
autoUpdater.on('update-not-available', (info) => {
updateState.status = 'not-available';
updateState.info = info || null;
updateState.error = null;
broadcastUpdateState();
});
autoUpdater.on('update-downloaded', (info) => {
updateState.status = 'downloaded';
updateState.info = info;
updateState.error = null;
broadcastUpdateState();
const wins = BrowserWindow.getAllWindows();
if (wins.length > 0) wins[0].webContents.send('update-downloaded', info);
});
autoUpdater.on('error', (err) => {
updateState.status = 'error';
updateState.error = err.message;
broadcastUpdateState();
const wins = BrowserWindow.getAllWindows();
if (wins.length > 0) wins[0].webContents.send('update-error', err.message);
});
ipcMain.on('check-for-updates', () => {
void runUpdateCheck('manual');
});
ipcMain.on('download-update', () => {
if (installInProgress) return;
updateState.status = 'downloading';
updateState.error = null;
broadcastUpdateState();
autoUpdater.downloadUpdate();
});
ipcMain.on('quit-and-install', () => {
installDownloadedUpdate();
});
// Check once on startup
setTimeout(() => {
void runUpdateCheck('startup');
}, 5000);
startPeriodicUpdateChecks();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
if (periodicUpdateTimer) {
clearInterval(periodicUpdateTimer);
periodicUpdateTimer = null;
}
});
function safeOrigin(value) {
try {
return new URL(value).origin;
} catch (_) {
return null;
}
}

5307
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

61
package.json Normal file
View file

@ -0,0 +1,61 @@
{
"name": "chattz-electron",
"version": "0.0.50",
"description": "Electron frontend for Chattz",
"author": "Pavel Flegr <pavelflegr@gmail.com>",
"homepage": "https://discord.flegr.me",
"repository": {
"type": "git",
"url": "https://git.flegr.me/pavel/discord.git"
},
"main": "main.js",
"scripts": {
"build:html": "node scripts/generate-html.js",
"start": "npm run build:html && electron .",
"dev": "npm run build:html && electron .",
"dist": "npm run build:html && electron-builder --publish never",
"dist:linux": "npm run build:html && electron-builder --linux --publish never",
"dist:win": "npm run build:html && electron-builder --win --publish never"
},
"build": {
"appId": "me.flegr.chattz",
"productName": "Chattz",
"linux": {
"target": [
"AppImage",
"rpm"
],
"category": "Chat",
"icon": "build/icon.png"
},
"win": {
"target": [
"nsis"
]
},
"publish": [
{
"provider": "generic",
"url": "https://discord.flegr.me/static/installers",
"channel": "latest"
}
],
"files": [
"main.js",
"package.json",
"desktop/",
"static/",
"!dist"
],
"directories": {
"output": "dist"
}
},
"devDependencies": {
"electron": "^34.5.8",
"electron-builder": "^25.1.8"
},
"dependencies": {
"electron-updater": "^6.8.3"
}
}

57
scripts/generate-html.js Normal file
View file

@ -0,0 +1,57 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const templatePath = path.join(root, 'shared-html', 'index.template.html');
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 = {
web: {
styles_href: '/static/styles.css',
lucide_src: '/static/vendor/lucide.min.js',
app_src: '/static/app.js?v=20260227-shared-core-1',
web_downloads: `<div class="auth-downloads">
<span>Desktop downloads:</span>
<a href="/static/installers/chattz-windows.exe">Windows</a>
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
</div>`,
desktop_updater: updaterMarkup,
outPath: path.join(root, 'static', 'index.html'),
},
desktop: {
styles_href: '../static/styles.css',
lucide_src: '../static/vendor/lucide.min.js',
app_src: 'app.js?v=20260227-shared-core-1',
web_downloads: '',
desktop_updater: updaterMarkup,
outPath: path.join(root, 'desktop', 'index.html'),
},
};
for (const variant of Object.values(variants)) {
let output = template;
for (const [key, value] of Object.entries(variant)) {
if (key === 'outPath') continue;
const token = `{{${key}}}`;
if (value === '') {
output = output.replace(new RegExp(`^[ \\t]*\\{\\{${key}\\}\\}[ \\t]*\\n?`, 'm'), '');
continue;
}
output = output.replaceAll(token, value);
}
if (/\{\{[a-z_]+\}\}/i.test(output)) {
throw new Error(`Unresolved template placeholders remain in ${variant.outPath}`);
}
fs.writeFileSync(
variant.outPath,
`<!-- Generated from shared-html/index.template.html. Do not edit directly. -->\n${output}`,
'utf8'
);
}

View file

@ -0,0 +1,279 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Chattz</title>
<link rel="stylesheet" href="{{styles_href}}" />
<!-- Lucide Icons -->
<script src="{{lucide_src}}"></script>
</head>
<body>
<div class="auth-screen" id="auth-screen">
<div class="auth-card">
<div class="brand-large">C</div>
<h1>Chattz</h1>
<p>Sign in to open your servers.</p>
<button id="login-btn">Login with Authentik</button>
{{web_downloads}}
<p id="status" class="status-line"></p>
</div>
</div>
<div class="shell hidden" id="main">
<aside class="server-rail">
<div class="brand" title="Home">
<i data-lucide="message-square"></i>
</div>
<div class="separator"></div>
<div id="guild-list" class="guild-list"></div>
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
<i data-lucide="plus"></i>
</button>
</aside>
<aside class="channel-sidebar">
<header class="sidebar-header clickable" id="guild-header">
<h2 id="guild-title">No server selected</h2>
<div class="sidebar-header-actions">
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
<i data-lucide="link-2"></i>
</button>
<i data-lucide="chevron-down"></i>
</div>
</header>
<div class="sidebar-scroll">
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Text Channels</span>
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
<i data-lucide="plus"></i>
</button>
</div>
<div id="channel-list" class="channel-list"></div>
</section>
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Voice Channels</span>
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
<i data-lucide="plus"></i>
</button>
</div>
<div id="voice-channel-list" class="channel-list"></div>
</section>
<section class="sidebar-group">
<div class="group-title">
<i data-lucide="chevron-down" class="group-toggle"></i>
<span>Direct Messages</span>
</div>
<div id="dm-list" class="channel-list"></div>
</section>
</div>
<div class="sidebar-footer">
<div id="voice-connection" class="voice-connection hidden">
<div class="vc-info">
<i data-lucide="signal-high" class="vc-icon"></i>
<div class="vc-text">
<span class="vc-status">Voice Connected</span>
<span id="vc-channel-name" class="vc-name">General</span>
</div>
<div class="vc-actions">
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
</div>
</div>
<div id="soundboard" class="soundboard-container hidden">
<div class="soundboard-header">
<span>Sound Board</span>
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
</div>
<div id="soundboard-grid" class="soundboard-grid"></div>
</div>
</div>
<div class="user-panel">
<div class="avatar-wrapper">
<div id="user-avatar" class="avatar">U</div>
<div class="status-dot online"></div>
</div>
<div class="user-info">
<div id="user-name" class="display-name">Username</div>
<div class="user-status">Online</div>
</div>
<div class="user-actions">
{{desktop_updater}}
<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>
</div>
</div>
</div>
</aside>
<main class="chat-pane">
<header class="chat-header">
<button id="mobile-menu-btn" class="mobile-toggle-btn" title="Menu">
<i data-lucide="menu"></i>
</button>
<i data-lucide="hash" class="header-icon"></i>
<h3 id="channel-title">Select a channel</h3>
<div style="flex:1"></div>
<button id="mobile-members-btn" class="mobile-toggle-btn" title="Members">
<i data-lucide="users"></i>
</button>
</header>
<div id="video-grid" class="video-grid hidden"></div>
<div id="message-list" class="message-list"></div>
<div class="chat-input-wrapper">
<form id="message-form" class="message-form">
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
<i data-lucide="paperclip"></i>
</button>
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
<i data-lucide="image"></i>
</button>
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
<button type="submit" class="hidden"></button>
</form>
</div>
</main>
<aside class="utility-sidebar" id="utility-sidebar">
<header class="sidebar-header">
<h2>Members</h2>
</header>
<div class="member-list-wrapper">
<div id="member-list" class="member-list"></div>
</div>
</aside>
</div>
<div id="overlay" class="overlay hidden"></div>
<!-- Modals -->
<div id="gif-picker" class="modal-container hidden">
<div class="modal gif-modal">
<div class="modal-header">
<h2>Select GIF</h2>
<button type="button" class="close-btn" id="gif-picker-close">
<i data-lucide="x"></i>
</button>
</div>
<div class="gif-search-wrapper">
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
<i data-lucide="search" class="search-icon"></i>
</div>
<div id="gif-results" class="gif-results-grid">
<!-- GIF results will be injected here -->
<div class="gif-loading hidden">Loading...</div>
</div>
</div>
</div>
<div id="modal-container" class="modal-container hidden">
<div class="modal">
<h2 id="modal-title">Create Server</h2>
<form id="guild-form" class="modal-form">
<div class="form-item">
<label for="guild-name">SERVER NAME</label>
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
<button type="submit" class="submit-btn">Create</button>
</div>
</form>
</div>
</div>
<div id="channel-modal" class="modal-container hidden">
<div class="modal">
<h2>Create Channel</h2>
<form id="channel-form" class="modal-form">
<div class="form-item">
<label>CHANNEL TYPE</label>
<div class="radio-group">
<label class="radio-item">
<input type="radio" name="channel-kind" value="text" checked>
<div class="radio-box">
<i data-lucide="hash"></i>
<div class="radio-text">
<strong>Text</strong>
<span>Send messages, images, and GIFs.</span>
</div>
</div>
</label>
<label class="radio-item">
<input type="radio" name="channel-kind" value="voice">
<div class="radio-box">
<i data-lucide="volume-2"></i>
<div class="radio-text">
<strong>Voice</strong>
<span>Hang out together with voice and video.</span>
</div>
</div>
</label>
</div>
</div>
<div class="form-item">
<label for="channel-name">CHANNEL NAME</label>
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
<button type="submit" class="submit-btn">Create Channel</button>
</div>
</form>
</div>
</div>
<div id="sound-modal" class="modal-container hidden">
<div class="modal">
<h2>Add Sound</h2>
<form id="sound-form" class="modal-form">
<div class="form-item">
<label for="sound-name">NAME</label>
<input id="sound-name" placeholder="Quack" maxlength="32" required />
</div>
<div class="form-item">
<label for="sound-icon">ICON (Emoji or Initials)</label>
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
</div>
<div class="form-item">
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
<input id="sound-file" type="file" accept="audio/*" required />
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
</div>
</form>
</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>
</body>
</html>

View file

@ -1,32 +1,25 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow};
use anyhow::{Result, anyhow};
use axum::{
Json,
extract::{FromRef, FromRequestParts},
http::{StatusCode, request::Parts},
http::{HeaderMap, StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use chrono::{Duration, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{AppState, db};
const SESSION_COOKIE: &str = "chattz_session";
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
#[derive(Debug, Serialize, Deserialize, Clone)]
struct SessionClaims {
sub: String,
exp: usize,
iat: usize,
}
pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
pub const SESSION_COOKIE: &str = "chattz_session";
const SESSION_TTL_DAYS: i64 = 30;
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: Uuid,
pub session_id: String,
}
#[derive(Debug)]
@ -43,6 +36,13 @@ impl ApiError {
}
}
pub fn forbidden(msg: &str) -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: msg.to_string(),
}
}
pub fn bad_request(msg: &str) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
@ -56,6 +56,13 @@ impl ApiError {
message: msg.to_string(),
}
}
pub fn service_unavailable(msg: &str) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: msg.to_string(),
}
}
}
#[derive(Serialize)]
@ -65,7 +72,13 @@ struct ErrorBody<'a> {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(ErrorBody { error: &self.message })).into_response()
(
self.status,
Json(ErrorBody {
error: &self.message,
}),
)
.into_response()
}
}
@ -77,25 +90,25 @@ impl From<anyhow::Error> for ApiError {
impl<S> FromRequestParts<S> for AuthUser
where
AppState: axum::extract::FromRef<S>,
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app = AppState::from_ref(state);
let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?;
let user_id = verify_session(&token, &app.settings.session_secret)
.map_err(|_| ApiError::unauthorized("invalid session"))?;
let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE)
.ok_or_else(|| ApiError::unauthorized("missing session cookie"))?;
let exists = db::user_exists(&app.db, user_id)
let user_id = db::touch_active_session(&app.db, &session_id)
.await
.map_err(|_| ApiError::unauthorized("session user not found"))?;
if !exists {
return Err(ApiError::unauthorized("session user not found"));
}
.map_err(|_| ApiError::unauthorized("invalid or expired session"))?
.ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?;
Ok(Self { id: user_id })
Ok(Self {
id: user_id,
session_id,
})
}
}
@ -103,6 +116,14 @@ pub fn new_oauth_state() -> 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 {
format!(
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
@ -112,69 +133,32 @@ pub fn make_oauth_state_cookie(value: &str, 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!(
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
name = OAUTH_STATE_COOKIE,
"{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}",
name = SESSION_COOKIE,
ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(),
secure_flag = if secure { "; Secure" } else { "" }
)
}
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
pub fn clear_session_cookie(secure: bool) -> String {
clear_cookie(SESSION_COOKIE, secure, "Strict")
}
pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|pair| {
let mut kv = pair.trim().splitn(2, '=');
let key = kv.next()?;
let value = kv.next()?;
(key == OAUTH_STATE_COOKIE).then(|| value.to_string())
(key == cookie_name).then(|| value.to_string())
})
}
pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result<String> {
let now = now_ts();
let claims = SessionClaims {
sub: user_id.to_string(),
iat: now as usize,
exp: (now + SESSION_TTL_SECS) as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.context("failed to encode session token")?;
Ok(format!(
"{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}{secure_flag}",
name = SESSION_COOKIE,
ttl = SESSION_TTL_SECS,
secure_flag = if secure { "; Secure" } else { "" }
))
}
pub fn clear_session_cookie(secure: bool) -> String {
format!(
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
name = SESSION_COOKIE,
secure_flag = if secure { "; Secure" } else { "" }
)
}
fn verify_session(token: &str, secret: &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")?;
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<()> {
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
if expected != query_state {
@ -183,19 +167,113 @@ pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str)
Ok(())
}
fn read_cookie(parts: &Parts, name: &str) -> Option<String> {
let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|pair| {
let mut kv = pair.trim().splitn(2, '=');
let key = kv.next()?;
let value = kv.next()?;
(key == name).then(|| value.to_string())
})
pub fn user_agent_hash(headers: &HeaderMap) -> Option<String> {
header_hash(headers, axum::http::header::USER_AGENT.as_str())
}
fn now_ts() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
pub fn ip_hash(headers: &HeaderMap) -> Option<String> {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.split(',').next().map(str::trim))
.filter(|value| !value.is_empty())
.map(hash_string)
.or_else(|| header_hash(headers, "x-real-ip"))
}
pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool {
headers
.get(axum::http::header::ORIGIN)
.and_then(|value| value.to_str().ok())
.map(|origin| origin == expected_origin)
.unwrap_or(false)
}
fn header_hash(headers: &HeaderMap, header_name: &str) -> Option<String> {
headers
.get(header_name)
.and_then(|v| v.to_str().ok())
.filter(|value| !value.trim().is_empty())
.map(hash_string)
}
fn hash_string(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0f));
}
out
}
fn nibble_to_hex(value: u8) -> char {
match value {
0..=9 => (b'0' + value) as char,
10..=15 => (b'a' + (value - 10)) as char,
_ => unreachable!(),
}
}
fn clear_cookie(name: &str, secure: bool, same_site: &str) -> String {
format!(
"{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}",
secure_flag = if secure { "; Secure" } else { "" }
)
}
#[cfg(test)]
mod tests {
use super::{
SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches,
read_cookie_from_headers,
};
use axum::http::{HeaderMap, header};
#[test]
fn hash_string_is_stable() {
assert_eq!(
hash_string("example"),
"50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c"
);
}
#[test]
fn reads_named_cookie_from_header() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"other=value; chattz_session=session-123; another=ok"
.parse()
.unwrap(),
);
assert_eq!(
read_cookie_from_headers(&headers, SESSION_COOKIE),
Some("session-123".to_string())
);
}
#[test]
fn origin_match_requires_exact_origin() {
let mut headers = HeaderMap::new();
headers.insert(header::ORIGIN, "http://localhost:3000".parse().unwrap());
assert!(origin_matches(&headers, "http://localhost:3000"));
assert!(!origin_matches(&headers, "https://localhost:3000"));
}
#[test]
fn session_cookie_has_strict_policy_and_clear_cookie_expires() {
let session_cookie = make_session_cookie("session-123", false);
let cleared_cookie = clear_session_cookie(false);
assert!(session_cookie.contains("HttpOnly"));
assert!(session_cookie.contains("SameSite=Strict"));
assert!(session_cookie.contains("Max-Age="));
assert!(cleared_cookie.contains("SameSite=Strict"));
assert!(cleared_cookie.contains("Max-Age=0"));
}
}

View file

@ -1,15 +1,30 @@
use crate::AppState;
use std::collections::HashMap;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::{RwLock, mpsc};
use uuid::Uuid;
use crate::{AppState, db};
#[derive(Clone)]
pub struct ChatClient {
tx: mpsc::UnboundedSender<ServerEvent>,
is_idle: bool,
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub struct OnlineUser {
pub user_id: Uuid,
pub online: bool,
pub idle: bool,
}
#[derive(Default)]
pub struct ChatHub {
// user_id -> sender
clients: RwLock<HashMap<Uuid, mpsc::UnboundedSender<ServerEvent>>>,
// user_id -> connection_id -> client
clients: RwLock<HashMap<Uuid, HashMap<Uuid, ChatClient>>>,
}
#[derive(Serialize, Clone)]
@ -26,68 +41,120 @@ pub enum ServerEvent {
UserPresence {
user_id: Uuid,
online: bool,
idle: bool,
},
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientEvent {
// Currently no interactive client events for the general chat WS
Ping,
SetIdleStatus { is_idle: bool },
}
impl ChatHub {
pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender<ServerEvent>) {
pub async fn add_client(
&self,
user_id: Uuid,
connection_id: Uuid,
tx: mpsc::UnboundedSender<ServerEvent>,
) -> Option<OnlineUser> {
let mut clients = self.clients.write().await;
clients.insert(user_id, tx);
let previous = aggregate_presence(clients.get(&user_id), user_id);
clients
.entry(user_id)
.or_default()
.insert(connection_id, ChatClient { tx, is_idle: false });
let current = aggregate_presence(clients.get(&user_id), user_id);
presence_delta(previous, current)
}
pub async fn remove_client(&self, user_id: Uuid) {
pub async fn remove_client(&self, user_id: Uuid, connection_id: Uuid) -> Option<OnlineUser> {
let mut clients = self.clients.write().await;
let 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);
}
pub async fn get_online_users(&self) -> Vec<Uuid> {
let clients = self.clients.read().await;
clients.keys().cloned().collect()
}
pub async fn broadcast_all(&self, event: ServerEvent) {
let clients = self.clients.read().await;
for tx in clients.values() {
let _ = tx.send(event.clone());
}
let current = aggregate_presence(clients.get(&user_id), user_id);
presence_delta(previous, current)
}
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec<OnlineUser> {
let clients = self.clients.read().await;
if let Some(tx) = clients.get(&user_id) {
let _ = tx.send(event);
}
visible_user_ids
.iter()
.filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id))
.filter(|presence| presence.online)
.collect()
}
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(tx) = clients.get(&user_id) {
let _ = tx.send(event.clone());
if let Some(connections) = clients.get(&user_id) {
for client in connections.values() {
let _ = client.tx.send(event.clone());
}
}
}
}
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
let clients = self.clients.read().await;
if let Some(connections) = clients.get(&user_id) {
for client in connections.values() {
let _ = client.tx.send(event.clone());
}
}
}
pub async fn set_idle_status(
&self,
user_id: Uuid,
connection_id: Uuid,
is_idle: bool,
) -> Option<OnlineUser> {
let mut clients = self.clients.write().await;
let previous = aggregate_presence(clients.get(&user_id), user_id);
if let Some(connections) = clients.get_mut(&user_id)
&& let Some(client) = connections.get_mut(&connection_id)
{
client.is_idle = is_idle;
}
let current = aggregate_presence(clients.get(&user_id), user_id);
presence_delta(previous, current)
}
}
pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
let (mut ws_sender, mut ws_receiver) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
let connection_id = Uuid::new_v4();
state.chat.add_client(user_id, tx).await;
if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await {
if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await {
state
.chat
.broadcast_all(ServerEvent::UserPresence {
user_id,
online: true,
})
.broadcast_to_many(
visible_user_ids,
ServerEvent::UserPresence {
user_id: presence.user_id,
online: presence.online,
idle: presence.idle,
},
)
.await;
}
}
let send_task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
@ -101,18 +168,187 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
});
while let Some(Ok(msg)) = ws_receiver.next().await {
if let Message::Close(_) = msg {
break;
match msg {
Message::Close(_) => break,
Message::Text(text) => {
if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
serde_json::from_str::<ClientEvent>(&text)
&& let Some(presence) = state
.chat
.set_idle_status(user_id, connection_id, is_idle)
.await
&& let Ok(visible_user_ids) =
db::list_visible_user_ids(&state.db, user_id).await
{
state
.chat
.broadcast_to_many(
visible_user_ids,
ServerEvent::UserPresence {
user_id: presence.user_id,
online: presence.online,
idle: presence.idle,
},
)
.await;
}
}
_ => {}
}
}
send_task.abort();
state.chat.remove_client(user_id).await;
if let Some(presence) = state.chat.remove_client(user_id, connection_id).await
&& let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await
{
state
.chat
.broadcast_all(ServerEvent::UserPresence {
user_id,
online: false,
})
.broadcast_to_many(
visible_user_ids,
ServerEvent::UserPresence {
user_id: presence.user_id,
online: presence.online,
idle: presence.idle,
},
)
.await;
}
}
fn aggregate_presence(
connections: Option<&HashMap<Uuid, ChatClient>>,
user_id: Uuid,
) -> Option<OnlineUser> {
let connections = connections?;
if connections.is_empty() {
return None;
}
let idle = connections.values().all(|client| client.is_idle);
Some(OnlineUser {
user_id,
online: true,
idle,
})
}
fn presence_delta(previous: Option<OnlineUser>, current: Option<OnlineUser>) -> Option<OnlineUser> {
match (previous, current) {
(None, None) => None,
(Some(prev), Some(curr)) if prev == curr => None,
(Some(prev), None) => Some(OnlineUser {
user_id: prev.user_id,
online: false,
idle: false,
}),
(_, Some(curr)) => Some(curr),
}
}
#[cfg(test)]
mod tests {
use super::{ChatHub, OnlineUser, ServerEvent};
use serde_json::json;
use tokio::sync::mpsc;
use uuid::Uuid;
#[tokio::test]
async fn multiple_connections_keep_user_online_until_last_disconnect() {
let hub = ChatHub::default();
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
let first_presence = hub.add_client(user_id, first_connection, tx1).await;
let second_presence = hub.add_client(user_id, second_connection, tx2).await;
let after_first_disconnect = hub.remove_client(user_id, first_connection).await;
let after_last_disconnect = hub.remove_client(user_id, second_connection).await;
assert_eq!(
first_presence,
Some(OnlineUser {
user_id,
online: true,
idle: false,
})
);
assert_eq!(second_presence, None);
assert_eq!(after_first_disconnect, None);
assert_eq!(
after_last_disconnect,
Some(OnlineUser {
user_id,
online: 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;
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,8 +1,11 @@
use anyhow::{Context, Result};
use anyhow::{Context, Result, anyhow};
use reqwest::Url;
#[derive(Clone, Debug)]
pub struct Settings {
pub port: u16,
pub app_base_url: String,
pub app_origin: String,
pub database_url: String,
pub oidc_client_id: String,
pub oidc_client_secret: String,
@ -11,21 +14,36 @@ pub struct Settings {
pub oidc_userinfo_url: String,
pub oidc_redirect_url: String,
pub oidc_scopes: String,
pub session_secret: String,
pub cookie_secure: bool,
pub session_cookie_secure: bool,
pub stun_urls: Vec<String>,
pub turn_urls: Vec<String>,
pub turn_username: Option<String>,
pub turn_password: Option<String>,
pub media: Option<MediaSettings>,
}
#[derive(Clone, Debug)]
pub struct MediaSettings {
pub account_id: String,
pub access_key_id: String,
pub secret_access_key: String,
pub bucket: String,
pub public_base_url: String,
}
impl Settings {
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 {
port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".into())
.parse()
.context("PORT must be a valid u16")?,
app_base_url: trim_trailing_slash(&app_base_url),
app_origin,
database_url: required("DATABASE_URL")?,
oidc_client_id: required("OIDC_CLIENT_ID")?,
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
@ -33,20 +51,51 @@ impl Settings {
oidc_token_url: required("OIDC_TOKEN_URL")?,
oidc_userinfo_url: required("OIDC_USERINFO_URL")?,
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()),
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")?,
oidc_scopes: std::env::var("OIDC_SCOPES")
.unwrap_or_else(|_| "openid profile email".to_string()),
session_cookie_secure: requires_secure_cookie(&app_url)?,
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
turn_urls: parse_csv_env("TURN_URLS", ""),
turn_username: optional("TURN_USERNAME"),
turn_password: optional("TURN_PASSWORD"),
media: MediaSettings::from_env()?,
})
}
}
impl MediaSettings {
fn from_env() -> Result<Option<Self>> {
let account_id = optional("R2_ACCOUNT_ID");
let access_key_id = optional("R2_ACCESS_KEY_ID");
let secret_access_key = optional("R2_SECRET_ACCESS_KEY");
let bucket = optional("R2_BUCKET");
let public_base_url = optional("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL"));
if account_id.is_none()
&& access_key_id.is_none()
&& secret_access_key.is_none()
&& bucket.is_none()
&& public_base_url.is_none()
{
return Ok(None);
}
Ok(Some(Self {
account_id: account_id.context("missing env var R2_ACCOUNT_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")?,
bucket: bucket.context("missing env var R2_BUCKET")?,
public_base_url: trim_trailing_slash(
&public_base_url.context("missing env var MEDIA_BASE_URL")?,
),
}))
}
pub fn endpoint_url(&self) -> String {
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
}
}
fn required(name: &str) -> Result<String> {
std::env::var(name).with_context(|| format!("missing env var {name}"))
}
@ -66,3 +115,105 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec<String> {
.map(ToString::to_string)
.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"
);
}
}

669
src/db.rs
View file

@ -1,20 +1,22 @@
use std::collections::HashSet;
use anyhow::{Result, anyhow};
use chrono::{Duration, Utc};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection,
DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
TransactionTrait, sea_query::OnConflict,
Statement, TransactionTrait, sea_query::OnConflict,
};
use uuid::Uuid;
use crate::{
entity::{
channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds,
users,
attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions,
soundboard_sounds, users,
},
models::{
BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor,
SoundboardSound, User,
Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite,
MessageWithAuthor, SoundboardSound, User,
},
};
@ -26,6 +28,88 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result<bool>
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(
db: &DatabaseConnection,
oidc_sub: &str,
@ -106,18 +190,58 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res
.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(
db: &DatabaseConnection,
owner_user_id: Uuid,
name: &str,
) -> Result<Guild> {
let txn = db.begin().await?;
let guild = guilds::Entity::insert(guilds::ActiveModel {
id: Set(Uuid::new_v4()),
name: Set(name.to_string()),
owner_user_id: Set(owner_user_id),
..Default::default()
})
.exec_with_returning(db)
.exec_with_returning(&txn)
.await?;
guild_members::Entity::insert(guild_members::ActiveModel {
@ -133,17 +257,13 @@ pub async fn create_guild(
.do_nothing()
.to_owned(),
)
.exec(db)
.exec(&txn)
.await?;
txn.commit().await?;
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(
db: &DatabaseConnection,
guild_id: Uuid,
@ -197,7 +317,12 @@ pub async fn create_invite(
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
let txn = db.begin().await?;
let invite = invites::Entity::find_by_id(code.to_string())
let invite = invites::Entity::find()
.from_raw_sql(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#,
[code.into()],
))
.one(&txn)
.await?
.ok_or_else(|| anyhow!("invite not found"))?;
@ -350,6 +475,63 @@ pub async fn create_message(
author_user_id: model.author_user_id,
author_display_name: user.display_name,
body: model.body,
attachments: Vec::new(),
created_at: model.created_at,
})
}
pub async fn create_message_with_attachment(
db: &DatabaseConnection,
channel_id: Uuid,
author_user_id: Uuid,
body: &str,
object_key: &str,
media_url: &str,
mime_type: &str,
size_bytes: i64,
original_filename: &str,
) -> Result<MessageWithAuthor> {
let txn = db.begin().await?;
let model = messages::Entity::insert(messages::ActiveModel {
id: Set(Uuid::new_v4()),
channel_id: Set(channel_id),
author_user_id: Set(author_user_id),
body: Set(body.to_string()),
..Default::default()
})
.exec_with_returning(&txn)
.await?;
let attachment = attachments::Entity::insert(attachments::ActiveModel {
id: Set(Uuid::new_v4()),
channel_message_id: Set(Some(model.id)),
direct_message_id: Set(None),
uploader_user_id: Set(author_user_id),
object_key: Set(object_key.to_string()),
media_url: Set(media_url.to_string()),
mime_type: Set(mime_type.to_string()),
size_bytes: Set(size_bytes),
original_filename: Set(original_filename.to_string()),
..Default::default()
})
.exec_with_returning(&txn)
.await?;
let user = users::Entity::find_by_id(author_user_id)
.one(&txn)
.await?
.ok_or_else(|| anyhow!("author not found"))?;
txn.commit().await?;
Ok(MessageWithAuthor {
id: model.id,
channel_id: model.channel_id,
author_user_id: model.author_user_id,
author_display_name: user.display_name,
body: model.body,
attachments: vec![map_attachment(attachment)],
created_at: model.created_at,
})
}
@ -367,6 +549,10 @@ pub async fn list_messages(
.all(db)
.await?;
let attachment_map =
list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect())
.await?;
Ok(rows
.into_iter()
.map(|(msg, user)| {
@ -379,6 +565,7 @@ pub async fn list_messages(
author_user_id: msg.author_user_id,
author_display_name,
body: msg.body,
attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(),
created_at: msg.created_at,
}
})
@ -412,6 +599,63 @@ pub async fn create_direct_message(
recipient_user_id: model.recipient_user_id,
author_display_name: user.display_name,
body: model.body,
attachments: Vec::new(),
created_at: model.created_at,
})
}
pub async fn create_direct_message_with_attachment(
db: &DatabaseConnection,
sender_user_id: Uuid,
recipient_user_id: Uuid,
body: &str,
object_key: &str,
media_url: &str,
mime_type: &str,
size_bytes: i64,
original_filename: &str,
) -> Result<DmMessageWithAuthor> {
let txn = db.begin().await?;
let model = direct_messages::Entity::insert(direct_messages::ActiveModel {
id: Set(Uuid::new_v4()),
sender_user_id: Set(sender_user_id),
recipient_user_id: Set(recipient_user_id),
body: Set(body.to_string()),
..Default::default()
})
.exec_with_returning(&txn)
.await?;
let attachment = attachments::Entity::insert(attachments::ActiveModel {
id: Set(Uuid::new_v4()),
channel_message_id: Set(None),
direct_message_id: Set(Some(model.id)),
uploader_user_id: Set(sender_user_id),
object_key: Set(object_key.to_string()),
media_url: Set(media_url.to_string()),
mime_type: Set(mime_type.to_string()),
size_bytes: Set(size_bytes),
original_filename: Set(original_filename.to_string()),
..Default::default()
})
.exec_with_returning(&txn)
.await?;
let user = users::Entity::find_by_id(sender_user_id)
.one(&txn)
.await?
.ok_or_else(|| anyhow!("sender not found"))?;
txn.commit().await?;
Ok(DmMessageWithAuthor {
id: model.id,
author_user_id: model.sender_user_id,
recipient_user_id: model.recipient_user_id,
author_display_name: user.display_name,
body: model.body,
attachments: vec![map_attachment(attachment)],
created_at: model.created_at,
})
}
@ -454,6 +698,9 @@ pub async fn list_direct_messages(
.map(|u| (u.id, u.display_name))
.collect();
let attachment_map =
list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?;
Ok(rows
.into_iter()
.map(|msg| DmMessageWithAuthor {
@ -465,11 +712,64 @@ pub async fn list_direct_messages(
.cloned()
.unwrap_or_else(|| "Unknown User".to_string()),
body: msg.body,
attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(),
created_at: msg.created_at,
})
.collect())
}
async fn list_attachments_for_channel_messages(
db: &DatabaseConnection,
message_ids: Vec<Uuid>,
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
if message_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let rows = attachments::Entity::find()
.filter(attachments::Column::ChannelMessageId.is_in(message_ids))
.order_by_asc(attachments::Column::CreatedAt)
.all(db)
.await?;
let mut grouped = std::collections::HashMap::<Uuid, Vec<Attachment>>::new();
for row in rows {
if let Some(message_id) = row.channel_message_id {
grouped
.entry(message_id)
.or_default()
.push(map_attachment(row));
}
}
Ok(grouped)
}
async fn list_attachments_for_direct_messages(
db: &DatabaseConnection,
message_ids: Vec<Uuid>,
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
if message_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let rows = attachments::Entity::find()
.filter(attachments::Column::DirectMessageId.is_in(message_ids))
.order_by_asc(attachments::Column::CreatedAt)
.all(db)
.await?;
let mut grouped = std::collections::HashMap::<Uuid, Vec<Attachment>>::new();
for row in rows {
if let Some(message_id) = row.direct_message_id {
grouped
.entry(message_id)
.or_default()
.push(map_attachment(row));
}
}
Ok(grouped)
}
pub async fn list_dm_conversations(
db: &DatabaseConnection,
current_user_id: Uuid,
@ -543,6 +843,16 @@ fn map_guild(model: guilds::Model) -> Guild {
}
}
fn map_attachment(model: attachments::Model) -> Attachment {
Attachment {
id: model.id,
media_url: model.media_url,
mime_type: model.mime_type,
size_bytes: model.size_bytes,
original_filename: model.original_filename,
}
}
fn map_channel(model: channels::Model) -> Channel {
Channel {
id: model.id,
@ -612,7 +922,10 @@ pub async fn create_sound(
created_by_user_id: Uuid,
name: &str,
icon: &str,
file_path: &str,
object_key: &str,
media_url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<SoundboardSound> {
let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel {
id: Set(Uuid::new_v4()),
@ -620,7 +933,13 @@ pub async fn create_sound(
created_by_user_id: Set(created_by_user_id),
name: Set(name.to_string()),
icon: Set(icon.to_string()),
file_path: Set(file_path.to_string()),
object_key: Set(Some(object_key.to_string())),
media_url: Set(media_url.to_string()),
mime_type: Set(Some(mime_type.to_string())),
size_bytes: Set(Some(size_bytes)),
// Keep the legacy column populated until every deployment has applied
// the nullable migration and old fallback paths are fully removed.
file_path: Set(Some(media_url.to_string())),
..Default::default()
})
.exec_with_returning(db)
@ -634,6 +953,11 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result<Option
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<()> {
soundboard_sounds::Entity::delete_by_id(id).exec(db).await?;
Ok(())
@ -645,8 +969,321 @@ fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound {
guild_id: model.guild_id,
name: model.name,
icon: model.icon,
file_path: model.file_path,
media_url: model.media_url,
mime_type: model.mime_type,
size_bytes: model.size_bytes,
created_by_user_id: model.created_by_user_id,
created_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}");
}
}

66
src/entity/attachments.rs Normal file
View file

@ -0,0 +1,66 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "attachments")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub channel_message_id: Option<Uuid>,
pub direct_message_id: Option<Uuid>,
pub uploader_user_id: Uuid,
pub object_key: String,
pub media_url: String,
pub mime_type: String,
pub size_bytes: i64,
pub original_filename: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::messages::Entity",
from = "Column::ChannelMessageId",
to = "super::messages::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Messages,
#[sea_orm(
belongs_to = "super::direct_messages::Entity",
from = "Column::DirectMessageId",
to = "super::direct_messages::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
DirectMessages,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UploaderUserId",
to = "super::users::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Users,
}
impl Related<super::messages::Entity> for Entity {
fn to() -> RelationDef {
Relation::Messages.def()
}
}
impl Related<super::direct_messages::Entity> for Entity {
fn to() -> RelationDef {
Relation::DirectMessages.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,8 +1,10 @@
pub mod attachments;
pub mod channels;
pub mod direct_messages;
pub mod guild_members;
pub mod guilds;
pub mod invites;
pub mod messages;
pub mod sessions;
pub mod soundboard_sounds;
pub mod users;

35
src/entity/sessions.rs Normal file
View file

@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "sessions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub user_id: Uuid,
pub expires_at: DateTimeWithTimeZone,
pub created_at: DateTimeWithTimeZone,
pub last_seen_at: DateTimeWithTimeZone,
pub revoked_at: Option<DateTimeWithTimeZone>,
pub user_agent_hash: Option<String>,
pub ip_hash: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -9,7 +9,11 @@ pub struct Model {
pub guild_id: Uuid,
pub name: String,
pub icon: String,
pub file_path: String,
pub object_key: Option<String>,
pub media_url: String,
pub mime_type: Option<String>,
pub size_bytes: Option<i64>,
pub file_path: Option<String>,
pub created_by_user_id: Uuid,
pub created_at: DateTimeWithTimeZone,
}

View file

@ -1,10 +1,12 @@
use axum::{
Json, Router,
extract::{Path, Query, State, WebSocketUpgrade},
body::Bytes,
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
};
use chrono::Datelike;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -12,10 +14,9 @@ use crate::{
AppState,
auth::{self, ApiError, AuthUser},
chat, db,
models::{Guild, SoundboardSound},
models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound},
voice,
};
use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
@ -29,6 +30,11 @@ pub fn routes() -> Router<AppState> {
"/dms/{other_user_id}/messages",
get(list_dm_messages).post(send_dm_message),
)
.route(
"/dms/{other_user_id}/attachments",
post(upload_dm_attachment),
)
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route("/presence", get(presence_list))
.route("/rtc-config", get(rtc_config))
.route("/guilds", get(list_guilds).post(create_guild))
@ -44,6 +50,7 @@ pub fn routes() -> Router<AppState> {
"/guilds/{guild_id}/sounds",
get(list_sounds).post(upload_sound),
)
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route(
"/guilds/{guild_id}/sounds/{sound_id}",
post(delete_sound_post).delete(delete_sound),
@ -53,6 +60,11 @@ pub fn routes() -> Router<AppState> {
"/channels/{channel_id}/messages",
get(list_messages).post(send_message),
)
.route(
"/channels/{channel_id}/attachments",
post(upload_channel_attachment),
)
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
.route("/ws", get(chat_ws))
}
@ -80,7 +92,7 @@ async fn auth_login(State(state): State<AppState>) -> Result<impl IntoResponse,
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure)
auth::make_oauth_state_cookie(&oauth_state, state.settings.session_cookie_secure)
.parse()
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
);
@ -146,7 +158,10 @@ async fn auth_callback(
Query(query): Query<AuthCallbackQuery>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
auth::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.state)
auth::validate_oauth_state(
auth::read_cookie_from_headers(&headers, auth::OAUTH_STATE_COOKIE),
&query.state,
)
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
let token_res = state
@ -213,40 +228,51 @@ async fn auth_callback(
.await
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
let session_cookie = auth::new_session_cookie(
let session_id = auth::new_session_id();
db::create_session(
&state.db,
&session_id,
user.id,
&state.settings.session_secret,
state.settings.cookie_secure,
auth::session_expiry(),
auth::user_agent_hash(&headers),
auth::ip_hash(&headers),
)
.map_err(|e| ApiError::internal(&e.to_string()))?;
.await
.map_err(|e| ApiError::internal(&format!("failed to create session: {e}")))?;
let mut headers = HeaderMap::new();
headers.append(
header::SET_COOKIE,
session_cookie
auth::clear_oauth_state_cookie(state.settings.session_cookie_secure)
.parse()
.map_err(|_| ApiError::internal("failed to set session cookie"))?,
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
);
headers.append(
header::SET_COOKIE,
auth::clear_oauth_state_cookie(state.settings.cookie_secure)
auth::make_session_cookie(&session_id, state.settings.session_cookie_secure)
.parse()
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
.map_err(|_| ApiError::internal("failed to set session cookie"))?,
);
Ok((headers, Redirect::to("/")))
}
async fn auth_logout(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
async fn auth_logout(
State(state): State<AppState>,
user: AuthUser,
) -> 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();
headers.append(
headers.insert(
header::SET_COOKIE,
auth::clear_session_cookie(state.settings.cookie_secure)
auth::clear_session_cookie(state.settings.session_cookie_secure)
.parse()
.map_err(|_| ApiError::internal("failed to clear session cookie"))?,
);
Ok((StatusCode::NO_CONTENT, headers))
Ok((headers, StatusCode::NO_CONTENT))
}
#[derive(Serialize)]
@ -307,9 +333,12 @@ async fn me(State(state): State<AppState>, user: AuthUser) -> Result<impl IntoRe
async fn presence_list(
State(state): State<AppState>,
_user: AuthUser,
user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let users = state.chat.get_online_users().await;
let visible_user_ids = db::list_visible_user_ids(&state.db, user.id)
.await
.map_err(|e| ApiError::internal(&format!("failed to load visible users: {e}")))?;
let users = state.chat.get_online_users_for(&visible_user_ids).await;
Ok(Json(users))
}
@ -650,11 +679,101 @@ async fn send_dm_message(
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
}
async fn voice_ws(
ws: WebSocketUpgrade,
async fn upload_channel_attachment(
State(state): State<AppState>,
user: AuthUser,
Path(channel_id): Path<Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> {
ensure_channel_member(&state, channel_id, user.id).await?;
let uploaded = upload_media_from_request(
&state,
&headers,
body,
channel_object_key_prefix(channel_id),
)
.await?;
let message = db::create_message_with_attachment(
&state.db,
channel_id,
user.id,
"",
&uploaded.object_key,
&uploaded.media_url,
&uploaded.mime_type,
uploaded.size_bytes,
&uploaded.original_filename,
)
.await
.map_err(|e| ApiError::internal(&format!("failed to create message attachment: {e}")))?;
let guild_id = db::guild_id_for_channel(&state.db, channel_id)
.await
.map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))?
.ok_or_else(|| ApiError::internal("channel not found after attachment upload"))?;
let members = db::list_guild_member_ids(&state.db, guild_id)
.await
.map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?;
broadcast_channel_message(&state, members, channel_id, &message).await;
Ok((StatusCode::CREATED, Json(message)))
}
async fn upload_dm_attachment(
State(state): State<AppState>,
user: AuthUser,
Path(other_user_id): Path<Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> {
if other_user_id == user.id {
return Err(ApiError::bad_request("cannot send dm to yourself"));
}
let other_user_exists = db::user_exists(&state.db, other_user_id)
.await
.map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?;
if !other_user_exists {
return Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "user not found".to_string(),
});
}
let uploaded = upload_media_from_request(
&state,
&headers,
body,
dm_object_key_prefix(user.id, other_user_id),
)
.await?;
let message = db::create_direct_message_with_attachment(
&state.db,
user.id,
other_user_id,
"",
&uploaded.object_key,
&uploaded.media_url,
&uploaded.mime_type,
uploaded.size_bytes,
&uploaded.original_filename,
)
.await
.map_err(|e| ApiError::internal(&format!("failed to create dm attachment: {e}")))?;
broadcast_dm_message(&state, user.id, other_user_id, &message).await;
Ok((StatusCode::CREATED, Json(message)))
}
async fn voice_ws(
State(state): State<AppState>,
user: AuthUser,
Path(channel_id): Path<Uuid>,
ws: WebSocketUpgrade,
) -> Result<impl IntoResponse, ApiError> {
ensure_channel_member(&state, channel_id, user.id).await?;
@ -676,9 +795,9 @@ async fn voice_ws(
}
async fn chat_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
user: AuthUser,
ws: WebSocketUpgrade,
) -> Result<impl IntoResponse, ApiError> {
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
}
@ -742,28 +861,348 @@ async fn upload_sound(
_ => return Err(ApiError::bad_request("missing fields")),
};
let extension = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp3");
let validated = validate_sound_upload(&file_name, &file_data)
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension);
let upload_dir = std::path::Path::new("static/uploads/soundboard");
tokio::fs::create_dir_all(upload_dir)
let storage = state
.media
.as_ref()
.ok_or_else(|| ApiError::service_unavailable("media storage is not configured"))?;
let object_key = format!(
"soundboard/{guild_id}/{}-{}",
Uuid::new_v4(),
sanitize_file_name(&file_name)
);
let media_url = storage
.upload_object(
&object_key,
file_data.to_vec(),
&validated.mime_type,
&file_name,
false,
)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let file_path = upload_dir.join(&safe_file_name);
tokio::fs::write(&file_path, file_data)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let web_path = format!("/static/uploads/soundboard/{}", safe_file_name);
let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?;
let sound = db::create_sound(
&state.db,
guild_id,
user.id,
name.trim(),
icon.trim(),
&object_key,
&media_url,
&validated.mime_type,
validated.size_bytes,
)
.await?;
Ok(Json(sound))
}
const MAX_MEDIA_UPLOAD_BYTES: usize = 50 * 1024 * 1024;
struct UploadedMedia {
object_key: String,
media_url: String,
mime_type: String,
size_bytes: i64,
original_filename: String,
}
#[derive(Debug)]
struct ValidatedUpload {
mime_type: String,
inline: bool,
size_bytes: i64,
}
async fn upload_media_from_request(
state: &AppState,
headers: &HeaderMap,
body: Bytes,
object_key_prefix: String,
) -> Result<UploadedMedia, ApiError> {
let storage = state
.media
.as_ref()
.ok_or_else(|| ApiError::service_unavailable("media storage is not configured"))?;
let original_filename = headers
.get("x-file-name")
.and_then(|v| v.to_str().ok())
.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
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
if body.is_empty() {
return Err(ApiError::bad_request("missing file upload"));
}
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()))?;
let safe_name = sanitize_file_name(&original_filename);
let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name);
let size_bytes = body.len() as i64;
let media_url = storage
.upload_object(
&object_key,
body.to_vec(),
&validated.mime_type,
&original_filename,
validated.inline,
)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
Ok(UploadedMedia {
object_key,
media_url,
mime_type: validated.mime_type,
size_bytes,
original_filename,
})
}
async fn broadcast_channel_message(
state: &AppState,
members: Vec<Uuid>,
channel_id: Uuid,
message: &MessageWithAuthor,
) {
state
.chat
.broadcast_to_many(
members,
chat::ServerEvent::MessageCreated {
channel_id,
message: serde_json::to_value(message).unwrap_or_default(),
},
)
.await;
}
async fn broadcast_dm_message(
state: &AppState,
current_user_id: Uuid,
other_user_id: Uuid,
message: &DmMessageWithAuthor,
) {
state
.chat
.broadcast_to_user(
current_user_id,
chat::ServerEvent::DmCreated {
other_user_id,
message: serde_json::to_value(message).unwrap_or_default(),
},
)
.await;
state
.chat
.broadcast_to_user(
other_user_id,
chat::ServerEvent::DmCreated {
other_user_id: current_user_id,
message: serde_json::to_value(message).unwrap_or_default(),
},
)
.await;
}
fn sanitize_file_name(file_name: &str) -> String {
let sanitized: String = file_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect();
let trimmed = sanitized.trim_matches(|ch| ch == '_' || ch == '.').trim();
if trimmed.is_empty() {
"upload.bin".to_string()
} else {
trimmed.to_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 {
let now = chrono::Utc::now();
format!(
"channels/{}/{:04}/{:02}",
channel_id,
now.year(),
now.month()
)
}
fn dm_object_key_prefix(user_a: Uuid, user_b: Uuid) -> String {
let now = chrono::Utc::now();
let (left, right) = if user_a <= user_b {
(user_a, user_b)
} else {
(user_b, user_a)
};
format!(
"dms/{}-{}/{:04}/{:02}",
left,
right,
now.year(),
now.month()
)
}
async fn delete_sound_post(
state: State<AppState>,
user: AuthUser,
@ -802,10 +1241,15 @@ async fn delete_sound(
});
}
// Delete file from disk
let relative_path = sound.file_path.trim_start_matches('/');
if let Err(e) = tokio::fs::remove_file(relative_path).await {
info!("failed to delete sound file {}: {}", relative_path, e);
let object_key = db::get_sound_object_key(&state.db, sound_id)
.await
.map_err(|e| ApiError::internal(&format!("failed to load sound storage key: {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)
@ -849,3 +1293,100 @@ async fn ensure_channel_member(
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

87
src/media.rs Normal file
View file

@ -0,0 +1,87 @@
use anyhow::{Result, anyhow};
use aws_config::BehaviorVersion;
use aws_sdk_s3::{
Client,
config::{Credentials, Region},
primitives::ByteStream,
};
use crate::config::MediaSettings;
#[derive(Clone)]
pub struct MediaStorage {
client: Client,
bucket: String,
public_base_url: String,
}
impl MediaStorage {
pub async fn new(settings: &MediaSettings) -> Result<Self> {
let shared_config = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("auto"))
.credentials_provider(Credentials::new(
settings.access_key_id.clone(),
settings.secret_access_key.clone(),
None,
None,
"chattz-r2",
))
.load()
.await;
let config = aws_sdk_s3::config::Builder::from(&shared_config)
.endpoint_url(settings.endpoint_url())
.force_path_style(true)
.build();
Ok(Self {
client: Client::from_conf(config),
bucket: settings.bucket.clone(),
public_base_url: settings.public_base_url.trim_end_matches('/').to_string(),
})
}
pub async fn upload_object(
&self,
object_key: &str,
bytes: Vec<u8>,
content_type: &str,
original_filename: &str,
inline: bool,
) -> Result<String> {
self.client
.put_object()
.bucket(&self.bucket)
.key(object_key)
.body(ByteStream::from(bytes))
.content_type(content_type)
.content_disposition(format!(
"{}; filename=\"{}\"",
if inline { "inline" } else { "attachment" },
sanitize_header_value(original_filename)
))
.send()
.await
.map_err(|e| anyhow!("failed to upload object to R2: {e}"))?;
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 {
value
.chars()
.filter(|c| *c != '\\' && *c != '"' && !c.is_control())
.collect()
}

View file

@ -11,13 +11,13 @@ impl MigrationTrait for Migration {
Table::create()
.table(Users::Table)
.if_not_exists()
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
.col(
ColumnDef::new(Users::Id)
.uuid()
ColumnDef::new(Users::OidcSub)
.string()
.not_null()
.primary_key(),
.unique_key(),
)
.col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key())
.col(ColumnDef::new(Users::Email).string())
.col(ColumnDef::new(Users::DisplayName).string().not_null())
.col(ColumnDef::new(Users::AvatarUrl).string())
@ -42,12 +42,7 @@ impl MigrationTrait for Migration {
Table::create()
.table(Guilds::Table)
.if_not_exists()
.col(
ColumnDef::new(Guilds::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Guilds::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Guilds::Name).string().not_null())
.col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null())
.col(
@ -108,12 +103,7 @@ impl MigrationTrait for Migration {
Table::create()
.table(Channels::Table)
.if_not_exists()
.col(
ColumnDef::new(Channels::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Channels::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Channels::GuildId).uuid().not_null())
.col(ColumnDef::new(Channels::Name).string().not_null())
.col(
@ -138,12 +128,7 @@ impl MigrationTrait for Migration {
Table::create()
.table(Messages::Table)
.if_not_exists()
.col(
ColumnDef::new(Messages::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Messages::ChannelId).uuid().not_null())
.col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null())
.col(ColumnDef::new(Messages::Body).text().not_null())

View file

@ -11,7 +11,12 @@ impl MigrationTrait for Migration {
Table::create()
.table(Invites::Table)
.if_not_exists()
.col(ColumnDef::new(Invites::Code).string().not_null().primary_key())
.col(
ColumnDef::new(Invites::Code)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Invites::GuildId).uuid().not_null())
.col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null())
.col(

View file

@ -17,8 +17,16 @@ impl MigrationTrait for Migration {
.not_null()
.primary_key(),
)
.col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null())
.col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null())
.col(
ColumnDef::new(DirectMessages::SenderUserId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(DirectMessages::RecipientUserId)
.uuid()
.not_null(),
)
.col(ColumnDef::new(DirectMessages::Body).text().not_null())
.col(
ColumnDef::new(DirectMessages::CreatedAt)

View file

@ -0,0 +1,122 @@
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(Attachments::Table)
.if_not_exists()
.col(
ColumnDef::new(Attachments::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Attachments::ChannelMessageId).uuid().null())
.col(ColumnDef::new(Attachments::DirectMessageId).uuid().null())
.col(ColumnDef::new(Attachments::UploaderUserId).uuid().not_null())
.col(ColumnDef::new(Attachments::ObjectKey).string().not_null())
.col(ColumnDef::new(Attachments::MediaUrl).string().not_null())
.col(ColumnDef::new(Attachments::MimeType).string().not_null())
.col(ColumnDef::new(Attachments::SizeBytes).big_integer().not_null())
.col(ColumnDef::new(Attachments::OriginalFilename).string().not_null())
.col(
ColumnDef::new(Attachments::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_attachments_channel_message")
.from(Attachments::Table, Attachments::ChannelMessageId)
.to(Messages::Table, Messages::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_attachments_direct_message")
.from(Attachments::Table, Attachments::DirectMessageId)
.to(DirectMessages::Table, DirectMessages::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_attachments_uploader")
.from(Attachments::Table, Attachments::UploaderUserId)
.to(Users::Table, Users::Id),
)
.check(
Expr::cust(
"(channel_message_id IS NOT NULL AND direct_message_id IS NULL) OR (channel_message_id IS NULL AND direct_message_id IS NOT NULL)",
),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_attachments_channel_message")
.table(Attachments::Table)
.col(Attachments::ChannelMessageId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_attachments_direct_message")
.table(Attachments::Table)
.col(Attachments::DirectMessageId)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Attachments::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Attachments {
Table,
Id,
ChannelMessageId,
DirectMessageId,
UploaderUserId,
ObjectKey,
MediaUrl,
MimeType,
SizeBytes,
OriginalFilename,
CreatedAt,
}
#[derive(DeriveIden)]
enum Messages {
Table,
Id,
}
#[derive(DeriveIden)]
enum DirectMessages {
Table,
Id,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}

View file

@ -0,0 +1,107 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Sessions::Table)
.if_not_exists()
.col(
ColumnDef::new(Sessions::Id)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Sessions::UserId).uuid().not_null())
.col(
ColumnDef::new(Sessions::ExpiresAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Sessions::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Sessions::LastSeenAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Sessions::RevokedAt).timestamp_with_time_zone())
.col(ColumnDef::new(Sessions::UserAgentHash).string())
.col(ColumnDef::new(Sessions::IpHash).string())
.foreign_key(
ForeignKey::create()
.name("fk_sessions_user")
.from(Sessions::Table, Sessions::UserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_sessions_user_id")
.table(Sessions::Table)
.col(Sessions::UserId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_sessions_expires_at")
.table(Sessions::Table)
.col(Sessions::ExpiresAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_sessions_revoked_at")
.table(Sessions::Table)
.col(Sessions::RevokedAt)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Sessions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Sessions {
Table,
Id,
UserId,
ExpiresAt,
CreatedAt,
LastSeenAt,
RevokedAt,
UserAgentHash,
IpHash,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}

View file

@ -0,0 +1,72 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(SoundboardSounds::Table)
.add_column(ColumnDef::new(SoundboardSounds::ObjectKey).string().null())
.add_column(ColumnDef::new(SoundboardSounds::MediaUrl).string().null())
.add_column(ColumnDef::new(SoundboardSounds::MimeType).string().null())
.add_column(
ColumnDef::new(SoundboardSounds::SizeBytes)
.big_integer()
.null(),
)
.to_owned(),
)
.await?;
manager
.get_connection()
.execute_unprepared(
r#"
UPDATE soundboard_sounds
SET media_url = file_path
WHERE media_url IS NULL
"#,
)
.await?;
manager
.alter_table(
Table::alter()
.table(SoundboardSounds::Table)
.modify_column(
ColumnDef::new(SoundboardSounds::MediaUrl)
.string()
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(SoundboardSounds::Table)
.drop_column(SoundboardSounds::ObjectKey)
.drop_column(SoundboardSounds::MediaUrl)
.drop_column(SoundboardSounds::MimeType)
.drop_column(SoundboardSounds::SizeBytes)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum SoundboardSounds {
Table,
ObjectKey,
MediaUrl,
MimeType,
SizeBytes,
}

View file

@ -0,0 +1,50 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(SoundboardSounds::Table)
.modify_column(ColumnDef::new(SoundboardSounds::FilePath).string().null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
UPDATE soundboard_sounds
SET file_path = media_url
WHERE file_path IS NULL
"#,
)
.await?;
manager
.alter_table(
Table::alter()
.table(SoundboardSounds::Table)
.modify_column(
ColumnDef::new(SoundboardSounds::FilePath)
.string()
.not_null(),
)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum SoundboardSounds {
Table,
FilePath,
}

View file

@ -5,6 +5,10 @@ mod m20260213_000002_invites;
mod m20260213_000003_channel_kind;
mod m20260213_000004_direct_messages;
mod m20260224_000005_soundboard;
mod m20260227_000006_attachments;
mod m20260227_000007_sessions;
mod m20260227_000008_soundboard_media;
mod m20260227_000009_soundboard_file_path_nullable;
pub struct Migrator;
@ -17,6 +21,10 @@ impl MigratorTrait for Migrator {
Box::new(m20260213_000003_channel_kind::Migration),
Box::new(m20260213_000004_direct_messages::Migration),
Box::new(m20260224_000005_soundboard::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

@ -31,12 +31,12 @@ pub struct Channel {
}
#[derive(Debug, Clone, Serialize)]
pub struct Message {
pub struct Attachment {
pub id: Uuid,
pub channel_id: Uuid,
pub author_user_id: Uuid,
pub body: String,
pub created_at: DateTimeWithTimeZone,
pub media_url: String,
pub mime_type: String,
pub size_bytes: i64,
pub original_filename: String,
}
#[derive(Debug, Clone, Serialize)]
@ -46,13 +46,14 @@ pub struct BasicUser {
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)]
#[derive(Debug, Clone, Serialize)]
pub struct MessageWithAuthor {
pub id: Uuid,
pub channel_id: Uuid,
pub author_user_id: Uuid,
pub author_display_name: String,
pub body: String,
pub attachments: Vec<Attachment>,
pub created_at: DateTimeWithTimeZone,
}
@ -71,6 +72,7 @@ pub struct DmMessageWithAuthor {
pub recipient_user_id: Uuid,
pub author_display_name: String,
pub body: String,
pub attachments: Vec<Attachment>,
pub created_at: DateTimeWithTimeZone,
}
@ -91,7 +93,9 @@ pub struct SoundboardSound {
pub guild_id: Uuid,
pub name: String,
pub icon: String,
pub file_path: String,
pub media_url: String,
pub mime_type: Option<String>,
pub size_bytes: Option<i64>,
pub created_by_user_id: Uuid,
pub created_at: DateTimeWithTimeZone,
}

View file

@ -10,7 +10,7 @@ use crate::{AppState, db};
#[derive(Default)]
pub struct VoiceHub {
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, HashMap<Uuid, ClientHandle>>>>,
}
#[derive(Clone)]
@ -19,6 +19,7 @@ struct ClientHandle {
is_sharing_video: bool,
is_sharing_screen: bool,
is_speaking: bool,
is_muted: bool,
tx: mpsc::UnboundedSender<ServerEvent>,
}
@ -29,6 +30,7 @@ pub struct VoiceParticipant {
pub is_sharing_video: bool,
pub is_sharing_screen: bool,
pub is_speaking: bool,
pub is_muted: bool,
}
#[derive(Serialize, Clone)]
@ -56,6 +58,10 @@ enum ServerEvent {
user_id: Uuid,
is_speaking: bool,
},
MuteStatusChanged {
user_id: Uuid,
is_muted: bool,
},
Signal {
from_user_id: Uuid,
kind: String,
@ -66,7 +72,7 @@ enum ServerEvent {
},
PlaySound {
user_id: Uuid,
sound_url: String,
media_url: String,
},
}
@ -87,9 +93,22 @@ enum ClientEvent {
SetSpeakingStatus {
is_speaking: bool,
},
PlaySound {
sound_url: String,
SetMuteStatus {
is_muted: bool,
},
PlaySound {
sound_id: Uuid,
},
}
#[derive(Default)]
struct VoiceStateChange {
joined: Option<VoiceParticipant>,
left_user_id: Option<Uuid>,
video_changed: Option<(Uuid, bool)>,
screen_changed: Option<(Uuid, bool)>,
speaking_changed: Option<(Uuid, bool)>,
mute_changed: Option<(Uuid, bool)>,
}
impl VoiceHub {
@ -100,13 +119,7 @@ impl VoiceHub {
};
room.iter()
.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,
})
.filter_map(|(user_id, connections)| aggregate_participant(Some(connections), *user_id))
.collect()
}
@ -114,61 +127,55 @@ impl VoiceHub {
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
display_name: String,
tx: mpsc::UnboundedSender<ServerEvent>,
) -> Vec<VoiceParticipant> {
) -> (Vec<VoiceParticipant>, VoiceStateChange) {
let mut rooms = self.rooms.write().await;
let room = rooms.entry(room_id).or_default();
let peers = room
.iter()
.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,
})
.filter_map(|(peer_id, connections)| aggregate_participant(Some(connections), *peer_id))
.collect::<Vec<_>>();
room.insert(
user_id,
let previous = aggregate_participant(room.get(&user_id), user_id);
room.entry(user_id).or_default().insert(
connection_id,
ClientHandle {
display_name: display_name.clone(),
display_name,
is_sharing_video: false,
is_sharing_screen: false,
is_speaking: false,
is_muted: false,
tx,
},
);
let current = aggregate_participant(room.get(&user_id), user_id);
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(),
});
}
(peers, diff_voice_state(previous, current))
}
peers
}
async fn leave(&self, room_id: Uuid, user_id: Uuid) {
async fn leave(&self, room_id: Uuid, user_id: Uuid, connection_id: Uuid) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
return VoiceStateChange::default();
};
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(connections) = room.get_mut(&user_id) {
connections.remove(&connection_id);
if connections.is_empty() {
room.remove(&user_id);
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() {
rooms.remove(&room_id);
}
diff_voice_state(previous, current)
}
pub async fn relay_signal(
@ -184,7 +191,10 @@ impl VoiceHub {
return;
};
if let Some(target) = room.get(&to_user_id) {
if let Some(target) = room
.get(&to_user_id)
.and_then(|connections| connections.values().next())
{
let _ = target.tx.send(ServerEvent::Signal {
from_user_id,
kind,
@ -193,97 +203,213 @@ impl VoiceHub {
}
}
pub async fn set_video_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_video: bool) {
async fn set_video_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_sharing_video: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
return VoiceStateChange::default();
};
if let Some(handle) = room.get_mut(&user_id) {
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_sharing_video = is_sharing_video;
for (peer_id, peer) in room.iter() {
if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::VideoStatusChanged {
user_id,
is_sharing_video,
});
}
}
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
pub async fn set_screen_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_screen: bool) {
async fn set_screen_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_sharing_screen: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
return VoiceStateChange::default();
};
if let Some(handle) = room.get_mut(&user_id) {
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_sharing_screen = is_sharing_screen;
for (peer_id, peer) in room.iter() {
if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::ScreenStatusChanged {
user_id,
is_sharing_screen,
});
}
}
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
pub async fn set_speaking_status(&self, room_id: Uuid, user_id: Uuid, is_speaking: bool) {
async fn set_speaking_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_speaking: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return;
return VoiceStateChange::default();
};
if let Some(handle) = room.get_mut(&user_id) {
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_speaking = is_speaking;
for (peer_id, peer) in room.iter() {
if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::SpeakingStatusChanged {
user_id,
is_speaking,
});
}
}
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) {
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;
};
for (peer_id, peer) in room.iter() {
for (peer_id, connections) in room.iter() {
if *peer_id == user_id {
continue;
}
for peer in connections.values() {
let _ = peer.tx.send(ServerEvent::PlaySound {
user_id,
sound_url: sound_url.clone(),
media_url: media_url.clone(),
});
}
}
}
async fn emit_change(&self, room_id: Uuid, user_id: Uuid, change: VoiceStateChange) {
if is_voice_state_change_empty(&change) {
return;
}
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
return;
};
if let Some(participant) = change.joined {
broadcast_voice_event(
room,
user_id,
ServerEvent::PeerJoined {
user_id: participant.user_id,
display_name: participant.display_name,
},
);
}
if let Some(left_user_id) = change.left_user_id {
broadcast_voice_event(
room,
user_id,
ServerEvent::PeerLeft {
user_id: left_user_id,
},
);
}
if let Some((changed_user_id, is_sharing_video)) = change.video_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::VideoStatusChanged {
user_id: changed_user_id,
is_sharing_video,
},
);
}
if let Some((changed_user_id, is_sharing_screen)) = change.screen_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::ScreenStatusChanged {
user_id: changed_user_id,
is_sharing_screen,
},
);
}
if let Some((changed_user_id, is_speaking)) = change.speaking_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::SpeakingStatusChanged {
user_id: changed_user_id,
is_speaking,
},
);
}
if let Some((changed_user_id, is_muted)) = change.mute_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::MuteStatusChanged {
user_id: changed_user_id,
is_muted,
},
);
}
}
}
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
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 (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
let connection_id = Uuid::new_v4();
let peers = state
let (peers, change) = state
.voice
.join(room_id, user_id, user.display_name.clone(), tx.clone())
.join(
room_id,
user_id,
connection_id,
user.display_name.clone(),
tx.clone(),
)
.await;
let _ = tx.send(ServerEvent::Peers { peers });
state.voice.emit_change(room_id, user_id, change).await;
let send_task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
@ -312,25 +438,42 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
.await;
}
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
state
let change = state
.voice
.set_video_status(room_id, user_id, is_sharing_video)
.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 }) => {
state
let change = state
.voice
.set_screen_status(room_id, user_id, is_sharing_screen)
.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
.voice
.set_speaking_status(room_id, user_id, is_speaking)
.play_sound(room_id, user_id, sound.media_url)
.await;
}
Ok(ClientEvent::PlaySound { sound_url }) => {
state.voice.play_sound(room_id, user_id, sound_url).await;
}
Err(err) => {
let _ = tx.send(ServerEvent::Error {
@ -345,5 +488,183 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
}
send_task.abort();
state.voice.leave(room_id, user_id).await;
let change = state.voice.leave(room_id, user_id, connection_id).await;
state.voice.emit_change(room_id, user_id, change).await;
}
fn aggregate_participant(
connections: Option<&HashMap<Uuid, ClientHandle>>,
user_id: Uuid,
) -> Option<VoiceParticipant> {
let connections = connections?;
if connections.is_empty() {
return None;
}
let mut handles = connections.values();
let first = handles.next()?;
Some(VoiceParticipant {
user_id,
display_name: first.display_name.clone(),
is_sharing_video: connections.values().any(|handle| handle.is_sharing_video),
is_sharing_screen: connections.values().any(|handle| handle.is_sharing_screen),
is_speaking: connections.values().any(|handle| handle.is_speaking),
is_muted: connections.values().all(|handle| handle.is_muted),
})
}
fn diff_voice_state(
previous: Option<VoiceParticipant>,
current: Option<VoiceParticipant>,
) -> VoiceStateChange {
match (previous, current) {
(None, None) => VoiceStateChange::default(),
(None, Some(current)) => VoiceStateChange {
joined: Some(current),
..Default::default()
},
(Some(previous), None) => VoiceStateChange {
left_user_id: Some(previous.user_id),
..Default::default()
},
(Some(previous), Some(current)) => VoiceStateChange {
video_changed: (previous.is_sharing_video != current.is_sharing_video)
.then_some((current.user_id, current.is_sharing_video)),
screen_changed: (previous.is_sharing_screen != current.is_sharing_screen)
.then_some((current.user_id, current.is_sharing_screen)),
speaking_changed: (previous.is_speaking != current.is_speaking)
.then_some((current.user_id, current.is_speaking)),
mute_changed: (previous.is_muted != current.is_muted)
.then_some((current.user_id, current.is_muted)),
..Default::default()
},
}
}
fn broadcast_voice_event(
room: &HashMap<Uuid, HashMap<Uuid, ClientHandle>>,
source_user_id: Uuid,
event: ServerEvent,
) {
for (peer_id, connections) in room {
if *peer_id == source_user_id {
continue;
}
for peer in connections.values() {
let _ = peer.tx.send(event.clone());
}
}
}
fn is_voice_state_change_empty(change: &VoiceStateChange) -> bool {
change.joined.is_none()
&& change.left_user_id.is_none()
&& change.video_changed.is_none()
&& change.screen_changed.is_none()
&& change.speaking_changed.is_none()
&& change.mute_changed.is_none()
}
#[cfg(test)]
mod tests {
use super::{VoiceHub, aggregate_participant};
use std::collections::HashMap;
use tokio::sync::mpsc;
use uuid::Uuid;
#[tokio::test]
async fn multiple_connections_keep_voice_participant_until_last_leave() {
let hub = VoiceHub::default();
let room_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
let (_peers, first_join) = hub
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
.await;
let (_peers, second_join) = hub
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
.await;
let first_leave = hub.leave(room_id, user_id, first_connection).await;
let second_leave = hub.leave(room_id, user_id, second_connection).await;
assert!(first_join.joined.is_some());
assert!(second_join.joined.is_none());
assert!(first_leave.left_user_id.is_none());
assert_eq!(second_leave.left_user_id, Some(user_id));
}
#[tokio::test]
async fn voice_mute_state_only_flips_when_all_connections_are_muted() {
let hub = VoiceHub::default();
let room_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
let _ = hub
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
.await;
let _ = hub
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
.await;
let first_mute = hub
.set_mute_status(room_id, user_id, first_connection, true)
.await;
let second_mute = hub
.set_mute_status(room_id, user_id, second_connection, true)
.await;
let unmute = hub
.set_mute_status(room_id, user_id, first_connection, false)
.await;
assert!(first_mute.mute_changed.is_none());
assert_eq!(second_mute.mute_changed, Some((user_id, true)));
assert_eq!(unmute.mute_changed, Some((user_id, false)));
}
#[test]
fn aggregate_participant_combines_connection_state() {
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let mut connections = HashMap::new();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
connections.insert(
first_connection,
super::ClientHandle {
display_name: "User".to_string(),
is_sharing_video: true,
is_sharing_screen: false,
is_speaking: false,
is_muted: true,
tx: tx1,
},
);
connections.insert(
second_connection,
super::ClientHandle {
display_name: "User".to_string(),
is_sharing_video: false,
is_sharing_screen: true,
is_speaking: true,
is_muted: false,
tx: tx2,
},
);
let participant = aggregate_participant(Some(&connections), user_id).unwrap();
assert!(participant.is_sharing_video);
assert!(participant.is_sharing_screen);
assert!(participant.is_speaking);
assert!(!participant.is_muted);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
<!-- Generated from shared-html/index.template.html. Do not edit directly. -->
<!doctype html>
<html lang="en">
@ -5,12 +6,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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" />
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script src="/static/vendor/lucide.min.js"></script>
</head>
<body>
@ -20,6 +18,11 @@
<h1>Chattz</h1>
<p>Sign in to open your servers.</p>
<button id="login-btn">Login with Authentik</button>
<div class="auth-downloads">
<span>Desktop downloads:</span>
<a href="/static/installers/chattz-windows.exe">Windows</a>
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
</div>
<p id="status" class="status-line"></p>
</div>
</div>
@ -40,6 +43,7 @@
<header class="sidebar-header clickable" id="guild-header">
<h2 id="guild-title">No server selected</h2>
<div class="sidebar-header-actions">
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
<i data-lucide="link-2"></i>
</button>
@ -113,6 +117,10 @@
<div class="user-status">Online</div>
</div>
<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="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
</div>
@ -138,6 +146,13 @@
<div class="chat-input-wrapper">
<form id="message-form" class="message-form">
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
<i data-lucide="paperclip"></i>
</button>
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
<i data-lucide="image"></i>
</button>
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
<button type="submit" class="hidden"></button>
</form>
@ -157,6 +172,24 @@
<div id="overlay" class="overlay hidden"></div>
<!-- Modals -->
<div id="gif-picker" class="modal-container hidden">
<div class="modal gif-modal">
<div class="modal-header">
<h2>Select GIF</h2>
<button type="button" class="close-btn" id="gif-picker-close">
<i data-lucide="x"></i>
</button>
</div>
<div class="gif-search-wrapper">
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
<i data-lucide="search" class="search-icon"></i>
</div>
<div id="gif-results" class="gif-results-grid">
<!-- GIF results will be injected here -->
<div class="gif-loading hidden">Loading...</div>
</div>
</div>
</div>
<div id="modal-container" class="modal-container hidden">
<div class="modal">
<h2 id="modal-title">Create Server</h2>
@ -238,7 +271,17 @@
</div>
</div>
<script src="/static/app.js" defer></script>
<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>
</body>
</html>

2405
static/shared/app-core.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -190,6 +190,27 @@ select {
transform: translateY(0);
}
.auth-downloads {
margin: 14px 0 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-muted);
}
.auth-downloads a {
color: var(--text-link);
text-decoration: none;
font-weight: 600;
}
.auth-downloads a:hover {
text-decoration: underline;
}
/* ═══════════════════════════════════════════════════════════ */
/* Main Layout */
/* ═══════════════════════════════════════════════════════════ */
@ -354,6 +375,44 @@ select {
color: var(--text-strong);
}
.copied-badge {
background: var(--brand);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.4px;
animation: fadeInOut 2s ease forwards;
pointer-events: none;
white-space: nowrap;
order: -1;
margin-right: 4px;
}
@keyframes fadeInOut {
0% {
opacity: 0;
transform: translateY(4px);
}
15% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
.header-action-btn i {
width: 16px;
height: 16px;
@ -512,6 +571,77 @@ select {
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
.status-dot.idle {
background: var(--yellow);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
}
.voice-muted-icon {
width: 14px;
height: 14px;
color: var(--danger);
margin-left: 6px;
vertical-align: text-bottom;
}
.user-volume-control {
width: 100%;
display: none;
/* Hidden by default */
align-items: center;
gap: 8px;
padding: 4px 8px 4px 28px;
margin-top: 2px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
overflow: hidden;
}
.user-volume-control.show-volume {
display: flex;
/* Show when active */
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
background: var(--bg-darker);
border-radius: 2px;
outline: none;
flex: 1;
height: 4px;
cursor: pointer;
min-width: 0;
}
.vol-pct {
font-size: 10px;
opacity: 0.6;
min-width: 30px;
text-align: right;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.volume-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.user-info {
flex: 1;
min-width: 0;
@ -638,7 +768,14 @@ select {
background: #000;
border-radius: var(--radius-lg);
overflow: hidden;
transition: box-shadow var(--transition);
transition: all var(--transition);
cursor: pointer;
z-index: 1;
}
.video-item:hover {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
z-index: 2;
}
.video-item video {
@ -646,6 +783,37 @@ select {
height: 100%;
object-fit: contain;
background: #000;
pointer-events: none;
}
/* Fullscreen Feed Support */
.video-grid.has-fullscreen {
display: flex;
padding: 0;
overflow: hidden;
position: absolute;
top: 48px;
/* Below chat-header */
left: 0;
right: 0;
bottom: 0;
z-index: 100;
background: var(--bg-main);
}
.video-grid.has-fullscreen .video-item {
display: none;
}
.video-grid.has-fullscreen .video-item.fullscreen {
display: block;
flex: 1;
width: 100%;
height: 100%;
border-radius: 0;
cursor: zoom-out;
z-index: 10;
aspect-ratio: auto;
}
.video-item .video-label {
@ -659,6 +827,8 @@ select {
border-radius: 100px;
font-size: 12px;
font-weight: 500;
pointer-events: none;
z-index: 5;
}
/* Voice Activity */
@ -674,6 +844,247 @@ select {
background: var(--green);
}
/* ═══════════════════════════════════════════════════════════ */
/* Chat Input */
/* ═══════════════════════════════════════════════════════════ */
.chat-input-wrapper {
padding: 0 16px 24px;
}
.message-form {
background: var(--bg-input);
border-radius: var(--radius-md);
padding: 2px 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.input-action-btn {
color: var(--text-muted);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.input-action-btn:hover {
color: var(--text-normal);
background: var(--bg-modifier-hover);
}
.message-form input {
flex: 1;
background: none;
border: 0;
padding: 12px 0;
font-size: 15px;
color: var(--text-normal);
}
.hidden-file-input {
display: none;
}
/* ═══════════════════════════════════════════════════════════ */
/* GIF Picker Modal */
/* ═══════════════════════════════════════════════════════════ */
.gif-modal {
width: min(500px, 95vw) !important;
max-height: 600px;
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
}
.gif-modal .modal-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.gif-modal .modal-header h2 {
margin: 0;
font-size: 18px;
}
.gif-search-wrapper {
padding: 16px 20px;
position: relative;
}
.gif-search-wrapper input {
width: 100%;
background: var(--bg-darker);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
padding: 12px 14px 12px 40px;
color: #fff;
font-size: 14px;
transition: border-color var(--transition);
}
.gif-search-wrapper input:focus {
border-color: var(--brand);
}
.gif-search-wrapper .search-icon {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--text-muted);
}
.gif-results-grid {
flex: 1;
overflow-y: auto;
padding: 0 20px 20px;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: min-content;
gap: 8px;
min-height: 300px;
}
.gif-item {
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
background: var(--bg-sidebar);
width: 100%;
aspect-ratio: 16 / 9;
transition: transform var(--transition);
}
.gif-item:hover {
transform: scale(1.02);
filter: brightness(1.1);
}
.gif-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.gif-loading {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* ── GIF in messages ────────────────────────────────────────── */
.msg-gif {
margin-top: 8px;
max-width: 100%;
max-height: 300px;
border-radius: var(--radius-md);
overflow: hidden;
display: block;
}
.msg-gif img {
max-width: 100%;
max-height: 300px;
display: block;
object-fit: contain;
}
.msg-attachment {
display: block;
margin-top: 8px;
}
.msg-attachment-image {
max-width: min(440px, 100%);
border-radius: var(--radius-md);
overflow: hidden;
}
.msg-attachment-image img {
width: 100%;
max-height: 360px;
object-fit: contain;
display: block;
background: rgba(0, 0, 0, 0.18);
}
.msg-attachment-video {
max-width: min(520px, 100%);
}
.msg-attachment-video video {
width: 100%;
max-height: 420px;
border-radius: var(--radius-md);
background: #000;
}
.msg-attachment-audio {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
max-width: min(420px, 100%);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.msg-attachment-audio audio {
width: 100%;
}
.msg-attachment-audio a {
color: var(--text-link);
font-size: 13px;
}
.msg-attachment-file {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
max-width: min(420px, 100%);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-normal);
}
.msg-attachment-file:hover {
background: rgba(255, 255, 255, 0.07);
}
.msg-attachment-file i {
width: 18px;
height: 18px;
color: var(--text-link);
flex-shrink: 0;
}
.msg-attachment-file span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-attachment-file small {
color: var(--text-muted);
margin-left: auto;
white-space: nowrap;
}
/* ═══════════════════════════════════════════════════════════ */
/* Sound Board */
/* ═══════════════════════════════════════════════════════════ */
@ -758,6 +1169,8 @@ select {
.sound-icon {
font-size: 24px;
display: block;
object-fit: contain;
}
.sound-name {
@ -786,6 +1199,7 @@ select {
/* Chat Pane */
/* ═══════════════════════════════════════════════════════════ */
.chat-pane {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-main);
@ -1064,6 +1478,13 @@ select {
letter-spacing: -0.3px;
}
.modal-copy {
margin: 0;
text-align: center;
color: var(--text-normal);
line-height: 1.5;
}
.form-item {
margin-bottom: 20px;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

12
static/vendor/lucide.min.js vendored Normal file

File diff suppressed because one or more lines are too long

33
test.html Normal file
View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="static/styles.css" />
<style>
body { margin: 0; height: 100vh; display: flex; }
.chat-pane { flex: 1; position: relative; background: #333; }
.chat-header { height: 48px; background: #222; }
</style>
</head>
<body>
<div class="chat-pane">
<div class="chat-header">Header</div>
<div id="video-grid" class="video-grid">
<div class="video-item" onclick="toggle(this)" style="display:flex; justify-content:center; align-items:center; color:white;">Click me</div>
<div class="video-item" style="color:white;">Another</div>
</div>
</div>
<script>
function toggle(el) {
const grid = document.getElementById('video-grid');
const isFullscreen = el.classList.contains('fullscreen');
document.querySelectorAll('.video-item').forEach(e => e.classList.remove('fullscreen'));
if (isFullscreen) {
grid.classList.remove('has-fullscreen');
} else {
el.classList.add('fullscreen');
grid.classList.add('has-fullscreen');
}
}
</script>
</body>
</html>

5
test_output.txt Normal file
View file

@ -0,0 +1,5 @@
> chattz-electron@0.1.0 start
> electron .
Loading desktop app from: file:///home/pavel/chattz/desktop/index.html?backend=https%3A%2F%2Fdiscord.flegr.me