Compare commits

..

78 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
6a47b867a4 fix
All checks were successful
/ upload (release) Successful in 20s
2026-02-24 02:17:41 +01:00
e4cc833ccc del
All checks were successful
/ upload (release) Successful in 22s
2026-02-24 02:12:26 +01:00
0c0aaa7048 fix lost files 2026-02-24 02:07:18 +01:00
477281a78f fix soundboard 2026-02-24 02:06:16 +01:00
e8b6db7bc1 cool redesign
All checks were successful
/ upload (release) Successful in 20s
2026-02-24 02:00:33 +01:00
33da605c03 soundboard
All checks were successful
/ upload (release) Successful in 27s
2026-02-24 01:41:24 +01:00
be1451ce45 signaling
All checks were successful
/ upload (release) Successful in 20s
2026-02-24 01:25:27 +01:00
e7c60e7c47 g
All checks were successful
/ upload (release) Successful in 20s
2026-02-24 01:18:00 +01:00
a2557c0194 vvv
All checks were successful
/ upload (release) Successful in 19s
2026-02-24 01:10:14 +01:00
737267567e ii
All checks were successful
/ upload (release) Successful in 18s
2026-02-24 01:02:26 +01:00
f35fdda7a2 m
All checks were successful
/ upload (release) Successful in 21s
2026-02-24 00:55:49 +01:00
5f92356a9a camera?
All checks were successful
/ upload (release) Successful in 23s
2026-02-24 00:49:56 +01:00
3117116d12 audio?
All checks were successful
/ upload (release) Successful in 1m25s
2026-02-24 00:42:52 +01:00
b7e96f915b android
All checks were successful
/ upload (release) Successful in 21s
2026-02-13 20:23:13 +01:00
a4bb3cfcca improvements
All checks were successful
/ upload (release) Successful in 19s
2026-02-13 20:10:52 +01:00
d1a68c635c ui stuff 2026-02-13 19:59:28 +01:00
0d79b42072 mobile
All checks were successful
/ upload (release) Successful in 21s
2026-02-13 19:30:55 +01:00
b353deffbb fix: mobile
All checks were successful
/ upload (release) Successful in 22s
2026-02-13 19:26:21 +01:00
c128acef86 fix
All checks were successful
/ upload (release) Successful in 20s
2026-02-13 18:29:13 +01:00
bc933d2c79 fix
All checks were successful
/ upload (release) Successful in 1m16s
2026-02-13 18:16:59 +01:00
49 changed files with 16086 additions and 1428 deletions

View file

@ -1,5 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
PORT=3000 PORT=3000
APP_BASE_URL=http://localhost:3000
# Authentik OIDC app values # Authentik OIDC app values
OIDC_CLIENT_ID=replace-me OIDC_CLIENT_ID=replace-me
@ -16,6 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr
TURN_USERNAME=replace-me TURN_USERNAME=replace-me
TURN_PASSWORD=replace-me TURN_PASSWORD=replace-me
# 32+ random chars; used to sign session cookies # Cloudflare R2 media uploads
SESSION_SECRET=replace-with-long-random-secret R2_ACCOUNT_ID=replace-me
COOKIE_SECURE=false 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: with:
node-version: 24 node-version: 24
- uses: actions/checkout@v6 - 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 - name: Cache Cargo registry
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -34,6 +79,10 @@ jobs:
export XDG_RUNTIME_DIR=/run/user/$(id -u) export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
systemctl --user stop discord systemctl --user stop discord
cp target/release/discord ~/discord/discord mkdir -p ~/discord
cp target/release/chattz ~/discord/discord
chmod +x ~/discord/discord chmod +x ~/discord/discord
systemctl --user start discord mkdir -p ~/discord/static/uploads
find ~/discord/static -mindepth 1 -maxdepth 1 ! -name 'uploads' -exec rm -rf {} +
cp -r static/* ~/discord/static/
systemctl --user start discord

4
.gitignore vendored
View file

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

891
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,17 +5,20 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
axum = { version = "0.8", features = ["macros", "ws"] } 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"] } chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15" dotenvy = "0.15"
jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] }
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] } sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
futures-util = "0.3" futures-util = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "fs"] } tower-http = { version = "0.6", features = ["trace", "fs"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

View file

@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using:
## What this includes ## What this includes
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`) - OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
- Signed session cookie auth - HttpOnly session cookie auth
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling - Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
- Guild invite codes (create + join) - Guild invite codes (create + join)
- Direct messages (DM) between users - Direct messages (DM) between users
@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`:
- `TURN_USERNAME` - `TURN_USERNAME`
- `TURN_PASSWORD` - `TURN_PASSWORD`
For production deployments:
- `APP_BASE_URL` must be your public app origin and should use `https`
- `MEDIA_BASE_URL` should be a separate media origin for user uploads
- uploads and soundboard require R2/object storage to be configured
3. Run app: 3. Run app:
```bash ```bash
@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`.
## Authentik setup notes ## Authentik setup notes
Create an Authentik OAuth2/OIDC provider + application and set: Create an Authentik OAuth2/OIDC provider + application and set:
- Redirect URI: `http://localhost:3000/auth/callback` - Redirect URI: `${APP_BASE_URL}/auth/callback`
- Scopes including at least: `openid profile email` - Scopes including at least: `openid profile email`
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match. If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug.
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling) - `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
All endpoints except health and auth flow require the session cookie from successful login. All endpoints except health and auth flow require the session cookie from successful login.
Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session.
## Notes ## Notes
@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server. Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity. For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload. The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
User uploads are served from the configured media origin, not from `/static`.
Mic filter modes in the UI: Mic filter modes in the UI:
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset) - `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)

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)
});

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::{Result, anyhow};
use anyhow::{Context, Result, anyhow};
use axum::{ use axum::{
Json, Json,
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{StatusCode, request::Parts}, http::{HeaderMap, StatusCode, request::Parts},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize}; use serde::Serialize;
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::{AppState, db}; use crate::{AppState, db};
const SESSION_COOKIE: &str = "chattz_session"; pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; pub const SESSION_COOKIE: &str = "chattz_session";
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7; const SESSION_TTL_DAYS: i64 = 30;
#[derive(Debug, Serialize, Deserialize, Clone)]
struct SessionClaims {
sub: String,
exp: usize,
iat: usize,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthUser { pub struct AuthUser {
pub id: Uuid, pub id: Uuid,
pub session_id: String,
} }
#[derive(Debug)] #[derive(Debug)]
@ -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 { pub fn bad_request(msg: &str) -> Self {
Self { Self {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
@ -56,6 +56,13 @@ impl ApiError {
message: msg.to_string(), message: msg.to_string(),
} }
} }
pub fn service_unavailable(msg: &str) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: msg.to_string(),
}
}
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -65,7 +72,13 @@ struct ErrorBody<'a> {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { 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 impl<S> FromRequestParts<S> for AuthUser
where where
AppState: axum::extract::FromRef<S>, AppState: FromRef<S>,
S: Send + Sync, S: Send + Sync,
{ {
type Rejection = ApiError; type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app = AppState::from_ref(state); let app = AppState::from_ref(state);
let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?; let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE)
let user_id = verify_session(&token, &app.settings.session_secret) .ok_or_else(|| ApiError::unauthorized("missing session cookie"))?;
.map_err(|_| ApiError::unauthorized("invalid session"))?;
let exists = db::user_exists(&app.db, user_id) let user_id = db::touch_active_session(&app.db, &session_id)
.await .await
.map_err(|_| ApiError::unauthorized("session user not found"))?; .map_err(|_| ApiError::unauthorized("invalid or expired session"))?
if !exists { .ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?;
return Err(ApiError::unauthorized("session user not found"));
}
Ok(Self { id: user_id }) Ok(Self {
id: user_id,
session_id,
})
} }
} }
@ -103,6 +116,14 @@ pub fn new_oauth_state() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
pub fn new_session_id() -> String {
Uuid::new_v4().simple().to_string()
}
pub fn session_expiry() -> chrono::DateTime<chrono::FixedOffset> {
(Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset()
}
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
format!( format!(
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}", "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
@ -112,69 +133,32 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
} }
pub fn clear_oauth_state_cookie(secure: bool) -> String { pub fn clear_oauth_state_cookie(secure: bool) -> String {
clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax")
}
pub fn make_session_cookie(value: &str, secure: bool) -> String {
format!( format!(
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", "{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}",
name = OAUTH_STATE_COOKIE, name = SESSION_COOKIE,
ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(),
secure_flag = if secure { "; Secure" } else { "" } secure_flag = if secure { "; Secure" } else { "" }
) )
} }
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> { pub fn clear_session_cookie(secure: bool) -> String {
clear_cookie(SESSION_COOKIE, secure, "Strict")
}
pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|pair| { raw.split(';').find_map(|pair| {
let mut kv = pair.trim().splitn(2, '='); let mut kv = pair.trim().splitn(2, '=');
let key = kv.next()?; let key = kv.next()?;
let value = kv.next()?; let value = kv.next()?;
(key == OAUTH_STATE_COOKIE).then(|| value.to_string()) (key == cookie_name).then(|| value.to_string())
}) })
} }
pub fn new_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<()> { pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?; let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
if expected != query_state { if expected != query_state {
@ -183,19 +167,113 @@ pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str)
Ok(()) Ok(())
} }
fn read_cookie(parts: &Parts, name: &str) -> Option<String> { pub fn user_agent_hash(headers: &HeaderMap) -> Option<String> {
let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?; header_hash(headers, axum::http::header::USER_AGENT.as_str())
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())
})
} }
fn now_ts() -> u64 { pub fn ip_hash(headers: &HeaderMap) -> Option<String> {
SystemTime::now() headers
.duration_since(UNIX_EPOCH) .get("x-forwarded-for")
.unwrap_or_else(|_| Duration::from_secs(0)) .and_then(|v| v.to_str().ok())
.as_secs() .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"));
}
} }

354
src/chat.rs Normal file
View file

@ -0,0 +1,354 @@
use std::collections::HashMap;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
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 -> connection_id -> client
clients: RwLock<HashMap<Uuid, HashMap<Uuid, ChatClient>>>,
}
#[derive(Serialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
MessageCreated {
channel_id: Uuid,
message: serde_json::Value,
},
DmCreated {
other_user_id: Uuid,
message: serde_json::Value,
},
UserPresence {
user_id: Uuid,
online: bool,
idle: bool,
},
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientEvent {
Ping,
SetIdleStatus { is_idle: bool },
}
impl ChatHub {
pub async fn add_client(
&self,
user_id: Uuid,
connection_id: Uuid,
tx: mpsc::UnboundedSender<ServerEvent>,
) -> Option<OnlineUser> {
let mut clients = self.clients.write().await;
let 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, 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);
}
}
let current = aggregate_presence(clients.get(&user_id), user_id);
presence_delta(previous, current)
}
pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec<OnlineUser> {
let clients = self.clients.read().await;
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(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();
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_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 {
let Ok(payload) = serde_json::to_string(&event) else {
continue;
};
if ws_sender.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = ws_receiver.next().await {
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();
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_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)] #[derive(Clone, Debug)]
pub struct Settings { pub struct Settings {
pub port: u16, pub port: u16,
pub app_base_url: String,
pub app_origin: String,
pub database_url: String, pub database_url: String,
pub oidc_client_id: String, pub oidc_client_id: String,
pub oidc_client_secret: String, pub oidc_client_secret: String,
@ -11,21 +14,36 @@ pub struct Settings {
pub oidc_userinfo_url: String, pub oidc_userinfo_url: String,
pub oidc_redirect_url: String, pub oidc_redirect_url: String,
pub oidc_scopes: String, pub oidc_scopes: String,
pub session_secret: String, pub session_cookie_secure: bool,
pub cookie_secure: bool,
pub stun_urls: Vec<String>, pub stun_urls: Vec<String>,
pub turn_urls: Vec<String>, pub turn_urls: Vec<String>,
pub turn_username: Option<String>, pub turn_username: Option<String>,
pub turn_password: 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 { impl Settings {
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let app_base_url = required("APP_BASE_URL")?;
let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?;
let app_origin = origin_from_url(&app_url)?;
Ok(Self { Ok(Self {
port: std::env::var("PORT") port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".into()) .unwrap_or_else(|_| "3000".into())
.parse() .parse()
.context("PORT must be a valid u16")?, .context("PORT must be a valid u16")?,
app_base_url: trim_trailing_slash(&app_base_url),
app_origin,
database_url: required("DATABASE_URL")?, database_url: required("DATABASE_URL")?,
oidc_client_id: required("OIDC_CLIENT_ID")?, oidc_client_id: required("OIDC_CLIENT_ID")?,
oidc_client_secret: required("OIDC_CLIENT_SECRET")?, oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
@ -33,20 +51,51 @@ impl Settings {
oidc_token_url: required("OIDC_TOKEN_URL")?, oidc_token_url: required("OIDC_TOKEN_URL")?,
oidc_userinfo_url: required("OIDC_USERINFO_URL")?, oidc_userinfo_url: required("OIDC_USERINFO_URL")?,
oidc_redirect_url: required("OIDC_REDIRECT_URL")?, oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()), oidc_scopes: std::env::var("OIDC_SCOPES")
session_secret: required("SESSION_SECRET")?, .unwrap_or_else(|_| "openid profile email".to_string()),
cookie_secure: std::env::var("COOKIE_SECURE") session_cookie_secure: requires_secure_cookie(&app_url)?,
.unwrap_or_else(|_| "false".into())
.parse()
.context("COOKIE_SECURE must be true/false")?,
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"), stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
turn_urls: parse_csv_env("TURN_URLS", ""), turn_urls: parse_csv_env("TURN_URLS", ""),
turn_username: optional("TURN_USERNAME"), turn_username: optional("TURN_USERNAME"),
turn_password: optional("TURN_PASSWORD"), 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> { fn required(name: &str) -> Result<String> {
std::env::var(name).with_context(|| format!("missing env var {name}")) 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) .map(ToString::to_string)
.collect() .collect()
} }
fn origin_from_url(url: &Url) -> Result<String> {
let host = url
.host_str()
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
let mut origin = format!("{}://{}", url.scheme(), host);
if let Some(port) = url.port() {
origin.push(':');
origin.push_str(&port.to_string());
}
Ok(origin)
}
fn requires_secure_cookie(url: &Url) -> Result<bool> {
match url.scheme() {
"https" => Ok(true),
"http" => {
let host = url
.host_str()
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
if matches!(host, "localhost" | "127.0.0.1" | "::1") {
Ok(false)
} else {
Err(anyhow!(
"APP_BASE_URL must use https outside localhost when session cookies are enabled"
))
}
}
other => Err(anyhow!("APP_BASE_URL scheme {other} is not supported")),
}
}
fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::{MediaSettings, origin_from_url, requires_secure_cookie, trim_trailing_slash};
use reqwest::Url;
#[test]
fn origin_from_url_preserves_explicit_port() {
let url = Url::parse("https://chat.example.com:8443/app").unwrap();
let origin = origin_from_url(&url).unwrap();
assert_eq!(origin, "https://chat.example.com:8443");
}
#[test]
fn secure_cookie_is_required_for_https_origins() {
let url = Url::parse("https://chat.example.com").unwrap();
assert_eq!(requires_secure_cookie(&url).unwrap(), true);
}
#[test]
fn localhost_http_is_allowed_without_secure_cookie() {
let url = Url::parse("http://localhost:3000").unwrap();
assert_eq!(requires_secure_cookie(&url).unwrap(), false);
}
#[test]
fn non_localhost_http_is_rejected() {
let url = Url::parse("http://chat.example.com").unwrap();
let err = requires_secure_cookie(&url).unwrap_err();
assert!(
err.to_string()
.contains("APP_BASE_URL must use https outside localhost")
);
}
#[test]
fn trim_trailing_slash_removes_only_suffix_slashes() {
assert_eq!(
trim_trailing_slash("https://chat.example.com///"),
"https://chat.example.com"
);
assert_eq!(
trim_trailing_slash("https://chat.example.com/app"),
"https://chat.example.com/app"
);
}
#[test]
fn media_endpoint_url_uses_account_id() {
let media = MediaSettings {
account_id: "acct123".to_string(),
access_key_id: "key".to_string(),
secret_access_key: "secret".to_string(),
bucket: "bucket".to_string(),
public_base_url: "https://media.example.com".to_string(),
};
assert_eq!(
media.endpoint_url(),
"https://acct123.r2.cloudflarestorage.com"
);
}
}

848
src/db.rs

File diff suppressed because it is too large Load diff

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,7 +1,10 @@
pub mod attachments;
pub mod channels; pub mod channels;
pub mod direct_messages; pub mod direct_messages;
pub mod guild_members; pub mod guild_members;
pub mod guilds; pub mod guilds;
pub mod invites; pub mod invites;
pub mod messages; pub mod messages;
pub mod sessions;
pub mod soundboard_sounds;
pub mod users; 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

@ -0,0 +1,53 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "soundboard_sounds")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub guild_id: Uuid,
pub name: String,
pub icon: 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,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guilds::Entity",
from = "Column::GuildId",
to = "super::guilds::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guilds,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::CreatedByUserId",
to = "super::users::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Users,
}
impl Related<super::guilds::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guilds.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

File diff suppressed because it is too large Load diff

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

View file

@ -11,7 +11,12 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(Invites::Table) .table(Invites::Table)
.if_not_exists() .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::GuildId).uuid().not_null())
.col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null()) .col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null())
.col( .col(

View file

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

View file

@ -0,0 +1,86 @@
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(SoundboardSounds::Table)
.if_not_exists()
.col(
ColumnDef::new(SoundboardSounds::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(SoundboardSounds::GuildId).uuid().not_null())
.col(ColumnDef::new(SoundboardSounds::Name).string().not_null())
.col(ColumnDef::new(SoundboardSounds::Icon).string().not_null())
.col(
ColumnDef::new(SoundboardSounds::FilePath)
.string()
.not_null(),
)
.col(
ColumnDef::new(SoundboardSounds::CreatedByUserId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(SoundboardSounds::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_soundboard_sounds_guild")
.from(SoundboardSounds::Table, SoundboardSounds::GuildId)
.to(Guilds::Table, Guilds::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_soundboard_sounds_user")
.from(SoundboardSounds::Table, SoundboardSounds::CreatedByUserId)
.to(Users::Table, Users::Id),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(SoundboardSounds::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum SoundboardSounds {
Table,
Id,
GuildId,
Name,
Icon,
FilePath,
CreatedByUserId,
CreatedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[derive(DeriveIden)]
enum Guilds {
Table,
Id,
}

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

@ -4,6 +4,11 @@ mod m20260213_000001_init;
mod m20260213_000002_invites; mod m20260213_000002_invites;
mod m20260213_000003_channel_kind; mod m20260213_000003_channel_kind;
mod m20260213_000004_direct_messages; 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; pub struct Migrator;
@ -15,6 +20,11 @@ impl MigratorTrait for Migrator {
Box::new(m20260213_000002_invites::Migration), Box::new(m20260213_000002_invites::Migration),
Box::new(m20260213_000003_channel_kind::Migration), Box::new(m20260213_000003_channel_kind::Migration),
Box::new(m20260213_000004_direct_messages::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)] #[derive(Debug, Clone, Serialize)]
pub struct Message { pub struct Attachment {
pub id: Uuid, pub id: Uuid,
pub channel_id: Uuid, pub media_url: String,
pub author_user_id: Uuid, pub mime_type: String,
pub body: String, pub size_bytes: i64,
pub created_at: DateTimeWithTimeZone, pub original_filename: String,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -46,13 +46,14 @@ pub struct BasicUser {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)] #[derive(Debug, Clone, Serialize)]
pub struct MessageWithAuthor { pub struct MessageWithAuthor {
pub id: Uuid, pub id: Uuid,
pub channel_id: Uuid, pub channel_id: Uuid,
pub author_user_id: Uuid, pub author_user_id: Uuid,
pub author_display_name: String, pub author_display_name: String,
pub body: String, pub body: String,
pub attachments: Vec<Attachment>,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
} }
@ -71,6 +72,7 @@ pub struct DmMessageWithAuthor {
pub recipient_user_id: Uuid, pub recipient_user_id: Uuid,
pub author_display_name: String, pub author_display_name: String,
pub body: String, pub body: String,
pub attachments: Vec<Attachment>,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
} }
@ -84,3 +86,16 @@ pub struct Invite {
pub max_uses: Option<i32>, pub max_uses: Option<i32>,
pub use_count: i32, pub use_count: i32,
} }
#[derive(Debug, Clone, Serialize)]
pub struct SoundboardSound {
pub id: Uuid,
pub guild_id: Uuid,
pub name: String,
pub icon: 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,12 +10,16 @@ use crate::{AppState, db};
#[derive(Default)] #[derive(Default)]
pub struct VoiceHub { pub struct VoiceHub {
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>, rooms: RwLock<HashMap<Uuid, HashMap<Uuid, HashMap<Uuid, ClientHandle>>>>,
} }
#[derive(Clone)] #[derive(Clone)]
struct ClientHandle { struct ClientHandle {
display_name: String, display_name: String,
is_sharing_video: bool,
is_sharing_screen: bool,
is_speaking: bool,
is_muted: bool,
tx: mpsc::UnboundedSender<ServerEvent>, tx: mpsc::UnboundedSender<ServerEvent>,
} }
@ -23,20 +27,53 @@ struct ClientHandle {
pub struct VoiceParticipant { pub struct VoiceParticipant {
pub user_id: Uuid, pub user_id: Uuid,
pub display_name: String, pub display_name: String,
pub is_sharing_video: bool,
pub is_sharing_screen: bool,
pub is_speaking: bool,
pub is_muted: bool,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum ServerEvent { enum ServerEvent {
Peers { peers: Vec<VoiceParticipant> }, Peers {
PeerJoined { user_id: Uuid, display_name: String }, peers: Vec<VoiceParticipant>,
PeerLeft { user_id: Uuid }, },
PeerJoined {
user_id: Uuid,
display_name: String,
},
PeerLeft {
user_id: Uuid,
},
VideoStatusChanged {
user_id: Uuid,
is_sharing_video: bool,
},
ScreenStatusChanged {
user_id: Uuid,
is_sharing_screen: bool,
},
SpeakingStatusChanged {
user_id: Uuid,
is_speaking: bool,
},
MuteStatusChanged {
user_id: Uuid,
is_muted: bool,
},
Signal { Signal {
from_user_id: Uuid, from_user_id: Uuid,
kind: String, kind: String,
data: serde_json::Value, data: serde_json::Value,
}, },
Error { message: String }, Error {
message: String,
},
PlaySound {
user_id: Uuid,
media_url: String,
},
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -47,6 +84,31 @@ enum ClientEvent {
kind: String, kind: String,
data: serde_json::Value, data: serde_json::Value,
}, },
SetVideoStatus {
is_sharing_video: bool,
},
SetScreenStatus {
is_sharing_screen: bool,
},
SetSpeakingStatus {
is_speaking: bool,
},
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 { impl VoiceHub {
@ -57,10 +119,7 @@ impl VoiceHub {
}; };
room.iter() room.iter()
.map(|(user_id, handle)| VoiceParticipant { .filter_map(|(user_id, connections)| aggregate_participant(Some(connections), *user_id))
user_id: *user_id,
display_name: handle.display_name.clone(),
})
.collect() .collect()
} }
@ -68,58 +127,58 @@ impl VoiceHub {
&self, &self,
room_id: Uuid, room_id: Uuid,
user_id: Uuid, user_id: Uuid,
connection_id: Uuid,
display_name: String, display_name: String,
tx: mpsc::UnboundedSender<ServerEvent>, tx: mpsc::UnboundedSender<ServerEvent>,
) -> Vec<VoiceParticipant> { ) -> (Vec<VoiceParticipant>, VoiceStateChange) {
let mut rooms = self.rooms.write().await; let mut rooms = self.rooms.write().await;
let room = rooms.entry(room_id).or_default(); let room = rooms.entry(room_id).or_default();
let peers = room let peers = room
.iter() .iter()
.map(|(peer_id, peer)| VoiceParticipant { .filter_map(|(peer_id, connections)| aggregate_participant(Some(connections), *peer_id))
user_id: *peer_id,
display_name: peer.display_name.clone(),
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
room.insert( let previous = aggregate_participant(room.get(&user_id), user_id);
user_id, room.entry(user_id).or_default().insert(
connection_id,
ClientHandle { ClientHandle {
display_name: display_name.clone(), display_name,
is_sharing_video: false,
is_sharing_screen: false,
is_speaking: false,
is_muted: false,
tx, tx,
}, },
); );
let current = aggregate_participant(room.get(&user_id), user_id);
for (peer_id, peer) in room.iter() { (peers, diff_voice_state(previous, current))
if *peer_id != user_id {
let _ = peer.tx.send(ServerEvent::PeerJoined {
user_id,
display_name: display_name.clone(),
});
}
}
peers
} }
async fn leave(&self, room_id: Uuid, user_id: Uuid) { async fn leave(&self, room_id: Uuid, user_id: Uuid, connection_id: Uuid) -> VoiceStateChange {
let mut rooms = self.rooms.write().await; let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else { let Some(room) = rooms.get_mut(&room_id) else {
return; return VoiceStateChange::default();
}; };
room.remove(&user_id); let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(connections) = room.get_mut(&user_id) {
for peer in room.values() { connections.remove(&connection_id);
let _ = peer.tx.send(ServerEvent::PeerLeft { user_id }); if connections.is_empty() {
room.remove(&user_id);
}
} }
let current = aggregate_participant(room.get(&user_id), user_id);
if room.is_empty() { if room.is_empty() {
rooms.remove(&room_id); rooms.remove(&room_id);
} }
diff_voice_state(previous, current)
} }
async fn relay_signal( pub async fn relay_signal(
&self, &self,
room_id: Uuid, room_id: Uuid,
from_user_id: Uuid, from_user_id: Uuid,
@ -132,7 +191,10 @@ impl VoiceHub {
return; return;
}; };
if let Some(target) = room.get(&to_user_id) { if let Some(target) = room
.get(&to_user_id)
.and_then(|connections| connections.values().next())
{
let _ = target.tx.send(ServerEvent::Signal { let _ = target.tx.send(ServerEvent::Signal {
from_user_id, from_user_id,
kind, kind,
@ -140,21 +202,214 @@ impl VoiceHub {
}); });
} }
} }
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 VoiceStateChange::default();
};
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_sharing_video = is_sharing_video;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
async fn set_screen_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_sharing_screen: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_sharing_screen = is_sharing_screen;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
async fn set_speaking_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_speaking: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_speaking = is_speaking;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
async fn set_mute_status(
&self,
room_id: Uuid,
user_id: Uuid,
connection_id: Uuid,
is_muted: bool,
) -> VoiceStateChange {
let mut rooms = self.rooms.write().await;
let Some(room) = rooms.get_mut(&room_id) else {
return VoiceStateChange::default();
};
let previous = aggregate_participant(room.get(&user_id), user_id);
if let Some(handle) = room
.get_mut(&user_id)
.and_then(|connections| connections.get_mut(&connection_id))
{
handle.is_muted = is_muted;
}
let current = aggregate_participant(room.get(&user_id), user_id);
diff_voice_state(previous, current)
}
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, media_url: String) {
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
return;
};
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,
media_url: media_url.clone(),
});
}
}
}
async fn emit_change(&self, room_id: Uuid, user_id: Uuid, change: VoiceStateChange) {
if is_voice_state_change_empty(&change) {
return;
}
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
return;
};
if let Some(participant) = change.joined {
broadcast_voice_event(
room,
user_id,
ServerEvent::PeerJoined {
user_id: participant.user_id,
display_name: participant.display_name,
},
);
}
if let Some(left_user_id) = change.left_user_id {
broadcast_voice_event(
room,
user_id,
ServerEvent::PeerLeft {
user_id: left_user_id,
},
);
}
if let Some((changed_user_id, is_sharing_video)) = change.video_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::VideoStatusChanged {
user_id: changed_user_id,
is_sharing_video,
},
);
}
if let Some((changed_user_id, is_sharing_screen)) = change.screen_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::ScreenStatusChanged {
user_id: changed_user_id,
is_sharing_screen,
},
);
}
if let Some((changed_user_id, is_speaking)) = change.speaking_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::SpeakingStatusChanged {
user_id: changed_user_id,
is_speaking,
},
);
}
if let Some((changed_user_id, is_muted)) = change.mute_changed {
broadcast_voice_event(
room,
user_id,
ServerEvent::MuteStatusChanged {
user_id: changed_user_id,
is_muted,
},
);
}
}
} }
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else { let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
return; return;
}; };
let Ok(Some(guild_id)) = db::guild_id_for_channel(&state.db, room_id).await else {
return;
};
let (mut ws_sender, mut ws_receiver) = socket.split(); let (mut ws_sender, mut ws_receiver) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>(); let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
let connection_id = Uuid::new_v4();
let peers = state let (peers, change) = state
.voice .voice
.join(room_id, user_id, user.display_name.clone(), tx.clone()) .join(
room_id,
user_id,
connection_id,
user.display_name.clone(),
tx.clone(),
)
.await; .await;
let _ = tx.send(ServerEvent::Peers { peers }); let _ = tx.send(ServerEvent::Peers { peers });
state.voice.emit_change(room_id, user_id, change).await;
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
@ -182,6 +437,44 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
.relay_signal(room_id, user_id, to_user_id, kind, data) .relay_signal(room_id, user_id, to_user_id, kind, data)
.await; .await;
} }
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
let change = state
.voice
.set_video_status(room_id, user_id, connection_id, is_sharing_video)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
let change = state
.voice
.set_screen_status(room_id, user_id, connection_id, is_sharing_screen)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
let change = state
.voice
.set_speaking_status(room_id, user_id, connection_id, is_speaking)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
let change = state
.voice
.set_mute_status(room_id, user_id, connection_id, is_muted)
.await;
state.voice.emit_change(room_id, user_id, change).await;
}
Ok(ClientEvent::PlaySound { sound_id }) => {
if let Ok(Some(sound)) = db::get_sound_by_id(&state.db, sound_id).await
&& sound.guild_id == guild_id
{
state
.voice
.play_sound(room_id, user_id, sound.media_url)
.await;
}
}
Err(err) => { Err(err) => {
let _ = tx.send(ServerEvent::Error { let _ = tx.send(ServerEvent::Error {
message: format!("invalid voice message: {err}"), message: format!("invalid voice message: {err}"),
@ -195,5 +488,183 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
} }
send_task.abort(); send_task.abort();
state.voice.leave(room_id, user_id).await; let change = state.voice.leave(room_id, user_id, connection_id).await;
state.voice.emit_change(room_id, user_id, change).await;
}
fn aggregate_participant(
connections: Option<&HashMap<Uuid, ClientHandle>>,
user_id: Uuid,
) -> Option<VoiceParticipant> {
let connections = connections?;
if connections.is_empty() {
return None;
}
let mut handles = connections.values();
let first = handles.next()?;
Some(VoiceParticipant {
user_id,
display_name: first.display_name.clone(),
is_sharing_video: connections.values().any(|handle| handle.is_sharing_video),
is_sharing_screen: connections.values().any(|handle| handle.is_sharing_screen),
is_speaking: connections.values().any(|handle| handle.is_speaking),
is_muted: connections.values().all(|handle| handle.is_muted),
})
}
fn diff_voice_state(
previous: Option<VoiceParticipant>,
current: Option<VoiceParticipant>,
) -> VoiceStateChange {
match (previous, current) {
(None, None) => VoiceStateChange::default(),
(None, Some(current)) => VoiceStateChange {
joined: Some(current),
..Default::default()
},
(Some(previous), None) => VoiceStateChange {
left_user_id: Some(previous.user_id),
..Default::default()
},
(Some(previous), Some(current)) => VoiceStateChange {
video_changed: (previous.is_sharing_video != current.is_sharing_video)
.then_some((current.user_id, current.is_sharing_video)),
screen_changed: (previous.is_sharing_screen != current.is_sharing_screen)
.then_some((current.user_id, current.is_sharing_screen)),
speaking_changed: (previous.is_speaking != current.is_speaking)
.then_some((current.user_id, current.is_speaking)),
mute_changed: (previous.is_muted != current.is_muted)
.then_some((current.user_id, current.is_muted)),
..Default::default()
},
}
}
fn broadcast_voice_event(
room: &HashMap<Uuid, HashMap<Uuid, ClientHandle>>,
source_user_id: Uuid,
event: ServerEvent,
) {
for (peer_id, connections) in room {
if *peer_id == source_user_id {
continue;
}
for peer in connections.values() {
let _ = peer.tx.send(event.clone());
}
}
}
fn is_voice_state_change_empty(change: &VoiceStateChange) -> bool {
change.joined.is_none()
&& change.left_user_id.is_none()
&& change.video_changed.is_none()
&& change.screen_changed.is_none()
&& change.speaking_changed.is_none()
&& change.mute_changed.is_none()
}
#[cfg(test)]
mod tests {
use super::{VoiceHub, aggregate_participant};
use std::collections::HashMap;
use tokio::sync::mpsc;
use uuid::Uuid;
#[tokio::test]
async fn multiple_connections_keep_voice_participant_until_last_leave() {
let hub = VoiceHub::default();
let room_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
let (_peers, first_join) = hub
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
.await;
let (_peers, second_join) = hub
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
.await;
let first_leave = hub.leave(room_id, user_id, first_connection).await;
let second_leave = hub.leave(room_id, user_id, second_connection).await;
assert!(first_join.joined.is_some());
assert!(second_join.joined.is_none());
assert!(first_leave.left_user_id.is_none());
assert_eq!(second_leave.left_user_id, Some(user_id));
}
#[tokio::test]
async fn voice_mute_state_only_flips_when_all_connections_are_muted() {
let hub = VoiceHub::default();
let room_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
let _ = hub
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
.await;
let _ = hub
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
.await;
let first_mute = hub
.set_mute_status(room_id, user_id, first_connection, true)
.await;
let second_mute = hub
.set_mute_status(room_id, user_id, second_connection, true)
.await;
let unmute = hub
.set_mute_status(room_id, user_id, first_connection, false)
.await;
assert!(first_mute.mute_changed.is_none());
assert_eq!(second_mute.mute_changed, Some((user_id, true)));
assert_eq!(unmute.mute_changed, Some((user_id, false)));
}
#[test]
fn aggregate_participant_combines_connection_state() {
let user_id = Uuid::new_v4();
let first_connection = Uuid::new_v4();
let second_connection = Uuid::new_v4();
let mut connections = HashMap::new();
let (tx1, _rx1) = mpsc::unbounded_channel();
let (tx2, _rx2) = mpsc::unbounded_channel();
connections.insert(
first_connection,
super::ClientHandle {
display_name: "User".to_string(),
is_sharing_video: true,
is_sharing_screen: false,
is_speaking: false,
is_muted: true,
tx: tx1,
},
);
connections.insert(
second_connection,
super::ClientHandle {
display_name: "User".to_string(),
is_sharing_video: false,
is_sharing_screen: true,
is_speaking: true,
is_muted: false,
tx: tx2,
},
);
let participant = aggregate_participant(Some(&connections), user_id).unwrap();
assert!(participant.is_sharing_video);
assert!(participant.is_sharing_screen);
assert!(participant.is_speaking);
assert!(!participant.is_muted);
}
} }

View file

@ -1,862 +1 @@
const state = { import "./shared/app-core.js";
me: null,
guilds: [],
channels: [],
dmConversations: [],
members: [],
voicePresence: new Map(),
selectedGuildId: null,
selectedTextChannelId: null,
selectedDmUserId: null,
selectedDmDisplayName: null,
selectedVoiceChannelId: null,
voice: {
ws: null,
joinedChannelId: null,
localStream: null,
rawStream: null,
audioContext: null,
denoiserNode: null,
deepFilterCore: null,
peerConnections: new Map(),
muted: false,
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
},
voicePresencePollId: null,
};
const el = {
authScreen: document.getElementById("auth-screen"),
loginBtn: document.getElementById("login-btn"),
main: document.getElementById("main"),
status: document.getElementById("status"),
// Guilds
guildList: document.getElementById("guild-list"),
addGuildBtn: document.getElementById("add-guild-btn"),
guildTitle: document.getElementById("guild-title"),
createInviteBtn: document.getElementById("create-invite-btn"),
// Channels
channelList: document.getElementById("channel-list"),
voiceChannelList: document.getElementById("voice-channel-list"),
addTextBtn: document.getElementById("add-text-btn"),
addVoiceBtn: document.getElementById("add-voice-btn"),
dmList: document.getElementById("dm-list"),
channelTitle: document.getElementById("channel-title"),
// Messages
messageList: document.getElementById("message-list"),
messageForm: document.getElementById("message-form"),
messageBody: document.getElementById("message-body"),
// User Panel
userName: document.getElementById("user-name"),
userAvatar: document.getElementById("user-avatar"),
logoutBtn: document.getElementById("logout-btn"),
// Voice Connection
voiceConnection: document.getElementById("voice-connection"),
vcChannelName: document.getElementById("vc-channel-name"),
voiceMuteBtn: document.getElementById("voice-mute-btn"),
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
// Members
memberList: document.getElementById("member-list"),
// Modals
modalContainer: document.getElementById("modal-container"),
guildForm: document.getElementById("guild-form"),
guildName: document.getElementById("guild-name"),
modalCancel: document.getElementById("modal-cancel"),
channelModal: document.getElementById("channel-modal"),
channelForm: document.getElementById("channel-form"),
channelName: document.getElementById("channel-name"),
channelModalCancel: document.getElementById("channel-modal-cancel"),
};
// --- API Helpers ---
async function api(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
"content-type": "application/json",
...(options.headers || {}),
},
credentials: "same-origin",
});
if (!res.ok) {
let detail = "request failed";
try {
const body = await res.json();
detail = body.error || detail;
} catch {}
throw new Error(`${res.status}: ${detail}`);
}
if (res.status === 204) return null;
return res.json();
}
// --- Utils ---
function shortName(name) {
if (!name || typeof name !== 'string') return "?";
const trimmed = name.trim();
if (!trimmed) return "?";
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
return words.map((w) => w[0]?.toUpperCase() || "").join("");
}
function escapeHtml(s) {
if (s === null || s === undefined) return "";
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatDate(isoString) {
const d = new Date(isoString);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
return d.toLocaleDateString();
}
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
const NSNET2_COMPAT_SUPPRESSION = 56;
let deepFilterLibPromise = null;
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
// --- Renderers ---
function renderGuilds() {
el.guildList.innerHTML = "";
for (const guild of state.guilds) {
const btn = document.createElement("button");
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
btn.title = guild.name;
btn.textContent = shortName(guild.name);
btn.onclick = async () => {
state.selectedGuildId = guild.id;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
renderDMs();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
startVoicePresencePolling();
renderMessages([]);
updateHeaderLabels();
};
el.guildList.appendChild(btn);
}
}
function renderChannels() {
el.channelList.innerHTML = "";
el.voiceChannelList.innerHTML = "";
for (const channel of state.channels) {
const row = document.createElement("button");
row.className = `channel-row ${
(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
}`;
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
row.innerHTML = `<i data-lucide="${iconName}"></i> <span>${escapeHtml(channel.name)}</span>`;
row.onclick = async () => {
if (channel.kind === 'text') {
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
state.selectedTextChannelId = channel.id;
renderChannels();
renderDMs();
updateHeaderLabels();
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
renderMessages(messages);
} else {
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
state.selectedVoiceChannelId = channel.id;
renderChannels();
renderDMs();
joinVoice();
}
};
if (channel.kind === 'text') {
el.channelList.appendChild(row);
} else {
el.voiceChannelList.appendChild(row);
const participants = state.voicePresence.get(channel.id) || [];
if (participants.length > 0) {
const pList = document.createElement("div");
pList.className = "voice-row-members channel-list";
pList.style.paddingLeft = "24px";
for (const p of participants) {
const pRow = document.createElement("div");
pRow.className = "channel-row";
pRow.style.padding = "2px 8px";
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
}
}
}
lucide.createIcons();
}
function renderDMs() {
el.dmList.innerHTML = "";
for (const dm of state.dmConversations) {
const row = document.createElement("button");
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
row.innerHTML = `<i data-lucide="message-circle"></i> <span>${escapeHtml(dm.display_name)}</span>`;
row.onclick = async () => {
state.selectedDmUserId = dm.user_id;
state.selectedDmDisplayName = dm.display_name;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
renderChannels();
renderDMs();
updateHeaderLabels();
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
renderMessages(messages);
};
el.dmList.appendChild(row);
}
lucide.createIcons();
}
function renderMessages(messages) {
el.messageList.innerHTML = "";
let lastAuthorId = null;
let lastTime = null;
for (const m of messages.slice().reverse()) {
const row = document.createElement("div");
const mDate = new Date(m.created_at);
const isGrouped = lastAuthorId === m.author_user_id &&
lastTime && (mDate - lastTime < 300000); // 5 minutes
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
const displayName = m.author_display_name || "Unknown User";
if (isGrouped) {
row.innerHTML = `<div class="msg-content"><div class="msg-body">${escapeHtml(m.body)}</div></div>`;
} else {
row.innerHTML = `
<div class="msg-avatar">${shortName(displayName)}</div>
<div class="msg-content">
<div class="msg-header">
<span class="msg-author">${escapeHtml(displayName)}</span>
<span class="msg-time">${formatDate(m.created_at)}</span>
</div>
<div class="msg-body">${escapeHtml(m.body)}</div>
</div>
`;
}
el.messageList.appendChild(row);
lastAuthorId = m.author_user_id;
lastTime = mDate;
}
el.messageList.scrollTop = el.messageList.scrollHeight;
}
function renderMembers() {
el.memberList.innerHTML = "";
for (const m of state.members) {
const row = document.createElement("div");
row.className = "member-row";
row.innerHTML = `
<div class="member-avatar">${shortName(m.display_name)}</div>
<div class="member-name">${escapeHtml(m.display_name)}</div>
`;
row.style.cursor = "pointer";
row.onclick = async () => {
if (state.me && m.id === state.me.id) return;
state.selectedDmUserId = m.id;
state.selectedDmDisplayName = m.display_name;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
renderChannels();
renderDMs();
updateHeaderLabels();
const messages = await api(`/dms/${m.id}/messages?limit=100`);
renderMessages(messages);
};
el.memberList.appendChild(row);
}
}
function updateHeaderLabels() {
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
el.guildTitle.textContent = guild ? guild.name : "No server selected";
if (state.selectedDmUserId) {
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
const dmName = displayName ? `@${displayName}` : "Direct Message";
el.channelTitle.textContent = dmName;
el.messageBody.placeholder = displayName
? `Message @${displayName}`
: "Message";
return;
}
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
}
async function createInviteLink() {
if (!state.selectedGuildId) {
alert("Select a server first.");
return;
}
try {
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
method: "POST",
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
});
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
try {
await navigator.clipboard.writeText(link);
alert(`Invite link copied:\n${link}`);
} catch {
prompt("Copy invite link:", link);
}
} catch (err) {
alert(err.message);
}
}
// --- Logic ---
async function loadGuilds() {
state.guilds = await api("/guilds");
renderGuilds();
}
async function loadDMConversations() {
state.dmConversations = await api("/dms");
renderDMs();
}
async function loadGuildMembers() {
if (!state.selectedGuildId) {
state.members = [];
renderMembers();
return;
}
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
renderMembers();
}
async function loadChannels() {
if (!state.selectedGuildId) {
state.channels = [];
renderChannels();
return;
}
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
renderChannels();
}
async function refreshVoicePresence() {
if (!state.selectedGuildId) {
state.voicePresence.clear();
renderChannels();
return;
}
try {
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
state.voicePresence.clear();
for (const entry of res.channels || []) {
state.voicePresence.set(entry.channel_id, entry.participants || []);
}
renderChannels();
} catch (err) {
console.warn("voice presence failed", err);
}
}
function startVoicePresencePolling() {
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
state.voicePresencePollId = setInterval(() => {
refreshVoicePresence().catch(() => {});
}, 3000);
}
// --- Voice ---
function getVoiceWsUrl(channelId) {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/channels/${channelId}/voice/ws`;
}
function shouldInitiateOffer(peerId) {
if (!state.me || !state.me.id) return false;
return state.me.id > peerId;
}
async function loadDeepFilterLib() {
if (!deepFilterLibPromise) {
deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH);
}
return deepFilterLibPromise;
}
async function buildDenoiserNode(audioContext) {
if (!audioContext.audioWorklet) {
throw new Error("AudioWorklet is not supported in this browser");
}
const df = await loadDeepFilterLib();
const core = new df.DeepFilterNet3Core({
sampleRate: 48000,
noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION,
assetConfig: {
cdnUrl: "/static/vendor/deepfilternet3",
},
});
await core.initialize();
const workletNode = await core.createAudioWorkletNode(audioContext);
state.voice.deepFilterCore = core;
return workletNode;
}
function stopAndClearAudioPipeline() {
if (state.voice.deepFilterCore) {
state.voice.deepFilterCore.destroy();
state.voice.deepFilterCore = null;
}
if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") {
state.voice.denoiserNode.destroy();
}
if (state.voice.localStream) {
for (const track of state.voice.localStream.getTracks()) track.stop();
}
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
for (const track of state.voice.rawStream.getTracks()) track.stop();
}
if (state.voice.audioContext) {
state.voice.audioContext.close().catch(() => {});
}
state.voice.localStream = null;
state.voice.rawStream = null;
state.voice.audioContext = null;
state.voice.denoiserNode = null;
state.voice.deepFilterCore = null;
}
async function buildAudioPipeline(rawStream) {
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(rawStream);
const destination = audioContext.createMediaStreamDestination();
state.voice.audioContext = audioContext;
let head = source;
const denoiserNode = await buildDenoiserNode(audioContext);
if (denoiserNode) {
head.connect(denoiserNode);
head = denoiserNode;
}
head.connect(destination);
state.voice.denoiserNode = denoiserNode;
return destination.stream;
}
async function createLocalVoiceStream() {
const constraints = {
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
},
video: false,
};
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
const localStream = await buildAudioPipeline(rawStream);
state.voice.rawStream = rawStream;
state.voice.localStream = localStream;
if (state.voice.muted) {
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = false;
});
}
}
function ensurePeerConnection(peerId) {
if (state.voice.peerConnections.has(peerId)) {
return state.voice.peerConnections.get(peerId);
}
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
if (state.voice.localStream) {
for (const track of state.voice.localStream.getTracks()) {
pc.addTrack(track, state.voice.localStream);
}
}
pc.onicecandidate = (event) => {
if (!event.candidate || !state.voice.ws) return;
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "ice",
data: event.candidate,
}));
};
pc.ontrack = (event) => {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
audio = document.createElement("audio");
audio.id = `audio-${peerId}`;
audio.autoplay = true;
audio.playsInline = true;
document.body.appendChild(audio);
}
audio.srcObject = event.streams[0];
};
state.voice.peerConnections.set(peerId, pc);
return pc;
}
async function sendOffer(peerId) {
const pc = ensurePeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: offer,
}));
}
async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId);
if (kind === "offer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: fromPeerId,
kind: "answer",
data: answer,
}));
} else if (kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
} else if (kind === "ice") {
try {
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
} catch (err) {
console.warn("failed to add ice candidate", err);
}
}
}
async function joinVoice() {
if (!state.selectedVoiceChannelId) return;
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return;
await leaveVoice();
try {
await createLocalVoiceStream();
} catch (err) {
console.error("microphone denied", err);
return;
}
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
state.voice.ws = ws;
state.voice.joinedChannelId = state.selectedVoiceChannelId;
ws.onopen = () => {
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
el.vcChannelName.textContent = channel ? channel.name : "Voice";
el.voiceConnection.classList.remove("hidden");
refreshVoicePresence().catch(() => {});
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "peers") {
for (const peer of msg.peers) {
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
}
} else if (msg.type === "peer_joined") {
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
} else if (msg.type === "peer_left") {
const pc = state.voice.peerConnections.get(msg.user_id);
if (pc) {
pc.close();
state.voice.peerConnections.delete(msg.user_id);
}
document.getElementById(`audio-${msg.user_id}`)?.remove();
} else if (msg.type === "signal") {
await handleSignal(msg.from_user_id, msg.kind, msg.data);
}
refreshVoicePresence().catch(() => {});
};
ws.onclose = () => {
el.voiceConnection.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear();
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
refreshVoicePresence().catch(() => {});
};
}
async function leaveVoice() {
if (state.voice.ws) state.voice.ws.close();
}
function toggleMute() {
if (!state.voice.localStream) return;
state.voice.muted = !state.voice.muted;
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = !state.voice.muted;
});
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
lucide.createIcons();
}
// --- Initialization ---
async function init() {
lucide.createIcons();
el.loginBtn.onclick = () => { location.href = "/auth/login"; };
el.logoutBtn.onclick = async () => {
await leaveVoice();
await api("/auth/logout", { method: "POST" });
location.reload();
};
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
el.createInviteBtn.onclick = createInviteLink;
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
el.addTextBtn.onclick = () => {
if (!state.selectedGuildId) {
alert("Create or select a server first.");
return;
}
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
el.channelModal.classList.remove("hidden");
};
el.addVoiceBtn.onclick = () => {
if (!state.selectedGuildId) {
alert("Create or select a server first.");
return;
}
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
el.channelModal.classList.remove("hidden");
};
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
el.guildForm.onsubmit = async (e) => {
e.preventDefault();
try {
const guild = await api("/guilds", {
method: "POST",
body: JSON.stringify({ name: el.guildName.value }),
});
el.guildName.value = "";
el.modalContainer.classList.add("hidden");
state.guilds.push(guild);
state.selectedGuildId = guild.id;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
renderMessages([]);
updateHeaderLabels();
} catch (err) { alert(err.message); }
};
el.channelForm.onsubmit = async (e) => {
e.preventDefault();
if (!state.selectedGuildId) {
alert("Select a server first.");
return;
}
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
const kind = kindInput ? kindInput.value : "text";
const channelName = el.channelName.value.trim();
if (!channelName) {
alert("Channel name is required.");
return;
}
try {
const created = await api("/channels", {
method: "POST",
body: JSON.stringify({
guild_id: state.selectedGuildId,
name: channelName,
kind: kind,
}),
});
el.channelName.value = "";
el.channelModal.classList.add("hidden");
await loadChannels();
if (created.kind === "text") {
state.selectedTextChannelId = created.id;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
renderChannels();
renderDMs();
updateHeaderLabels();
const messages = await api(`/channels/${created.id}/messages?limit=100`);
renderMessages(messages);
} else {
state.selectedVoiceChannelId = created.id;
state.selectedTextChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
renderChannels();
renderDMs();
updateHeaderLabels();
}
} catch (err) { alert(err.message); }
};
el.messageForm.onsubmit = async (e) => {
e.preventDefault();
const body = el.messageBody.value.trim();
if (!body) return;
try {
if (state.selectedTextChannelId) {
await api(`/channels/${state.selectedTextChannelId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
} else if (state.selectedDmUserId) {
await api(`/dms/${state.selectedDmUserId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
} else {
return;
}
el.messageBody.value = "";
const messages = state.selectedTextChannelId
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
renderMessages(messages);
await loadDMConversations();
renderDMs();
} catch (err) { alert(err.message); }
};
el.voiceMuteBtn.onclick = toggleMute;
el.voiceLeaveBtn.onclick = leaveVoice;
try {
state.me = await api("/me");
if (state.me && state.me.display_name) {
el.userName.textContent = state.me.display_name;
el.userAvatar.textContent = shortName(state.me.display_name);
}
el.authScreen.classList.add("hidden");
el.main.classList.remove("hidden");
const params = new URLSearchParams(location.search);
const inviteCode = params.get("invite");
if (inviteCode) {
try {
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
method: "POST",
});
localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
} catch (err) {
alert(`Failed to join invite: ${err.message}`);
} finally {
params.delete("invite");
const nextQuery = params.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl);
}
}
await loadGuilds();
await loadDMConversations();
if (state.guilds.length > 0) {
const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
state.selectedGuildId = guild.id;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
updateHeaderLabels();
} else {
state.members = [];
renderMembers();
updateHeaderLabels();
}
startVoicePresencePolling();
lucide.createIcons();
} catch (err) {
console.error("init failed", err);
el.authScreen.classList.remove("hidden");
el.main.classList.add("hidden");
}
}
init();

View file

@ -1,13 +1,16 @@
<!-- Generated from shared-html/index.template.html. Do not edit directly. -->
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Chattz</title> <title>Chattz</title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
<!-- Lucide Icons --> <!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script> <script src="/static/vendor/lucide.min.js"></script>
</head> </head>
<body> <body>
<div class="auth-screen" id="auth-screen"> <div class="auth-screen" id="auth-screen">
<div class="auth-card"> <div class="auth-card">
@ -15,6 +18,11 @@
<h1>Chattz</h1> <h1>Chattz</h1>
<p>Sign in to open your servers.</p> <p>Sign in to open your servers.</p>
<button id="login-btn">Login with Authentik</button> <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> <p id="status" class="status-line"></p>
</div> </div>
</div> </div>
@ -35,6 +43,7 @@
<header class="sidebar-header clickable" id="guild-header"> <header class="sidebar-header clickable" id="guild-header">
<h2 id="guild-title">No server selected</h2> <h2 id="guild-title">No server selected</h2>
<div class="sidebar-header-actions"> <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"> <button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
<i data-lucide="link-2"></i> <i data-lucide="link-2"></i>
</button> </button>
@ -83,12 +92,21 @@
<span id="vc-channel-name" class="vc-name">General</span> <span id="vc-channel-name" class="vc-name">General</span>
</div> </div>
<div class="vc-actions"> <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-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button> <button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
</div> </div>
</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>
<div class="user-panel"> <div class="user-panel">
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<div id="user-avatar" class="avatar">U</div> <div id="user-avatar" class="avatar">U</div>
@ -99,6 +117,10 @@
<div class="user-status">Online</div> <div class="user-status">Online</div>
</div> </div>
<div class="user-actions"> <div class="user-actions">
<div id="update-notifier" class="update-notifier hidden">
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
</div>
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button> <button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button> <button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
</div> </div>
@ -108,21 +130,36 @@
<main class="chat-pane"> <main class="chat-pane">
<header class="chat-header"> <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> <i data-lucide="hash" class="header-icon"></i>
<h3 id="channel-title">Select a channel</h3> <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> </header>
<div id="video-grid" class="video-grid hidden"></div>
<div id="message-list" class="message-list"></div> <div id="message-list" class="message-list"></div>
<div class="chat-input-wrapper"> <div class="chat-input-wrapper">
<form id="message-form" class="message-form"> <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 /> <input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
<button type="submit" class="hidden"></button> <button type="submit" class="hidden"></button>
</form> </form>
</div> </div>
</main> </main>
<aside class="utility-sidebar"> <aside class="utility-sidebar" id="utility-sidebar">
<header class="sidebar-header"> <header class="sidebar-header">
<h2>Members</h2> <h2>Members</h2>
</header> </header>
@ -132,7 +169,27 @@
</aside> </aside>
</div> </div>
<div id="overlay" class="overlay hidden"></div>
<!-- Modals --> <!-- 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 id="modal-container" class="modal-container hidden">
<div class="modal"> <div class="modal">
<h2 id="modal-title">Create Server</h2> <h2 id="modal-title">Create Server</h2>
@ -190,6 +247,41 @@
</div> </div>
</div> </div>
<script src="/static/app.js" defer></script> <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="/static/app.js?v=20260227-shared-core-1"></script>
</body> </body>
</html> </html>

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

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