diff --git a/.env.example b/.env.example index c062b55..1390efc 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz PORT=3000 +APP_BASE_URL=http://localhost:3000 # Authentik OIDC app values OIDC_CLIENT_ID=replace-me @@ -16,13 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr TURN_USERNAME=replace-me TURN_PASSWORD=replace-me -# 32+ random chars; used to sign session cookies -SESSION_SECRET=replace-with-long-random-secret -COOKIE_SECURE=false - # Cloudflare R2 media uploads R2_ACCOUNT_ID=replace-me R2_ACCESS_KEY_ID=replace-me R2_SECRET_ACCESS_KEY=replace-me R2_BUCKET=chattz-media -R2_PUBLIC_BASE_URL=https://media.example.com +MEDIA_BASE_URL=https://media.example.com diff --git a/Cargo.lock b/Cargo.lock index b496651..c44764d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,13 +675,14 @@ dependencies = [ "chrono", "dotenvy", "futures-util", - "jsonwebtoken", "reqwest", "sea-orm", "sea-orm-migration", "serde", "serde_json", + "sha2", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -1648,21 +1649,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1825,16 +1811,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1967,16 +1943,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2765,18 +2731,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -3131,7 +3085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index d7ece96..6dbd768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,14 +10,15 @@ aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", axum = { version = "0.8", features = ["macros", "ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" -jsonwebtoken = "9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } +sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] } sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" futures-util = "0.3" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] } +tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/README.md b/README.md index af5e8f0..2708a60 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using: ## What this includes - OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`) -- Signed session cookie auth +- HttpOnly session cookie auth - Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling - Guild invite codes (create + join) - Direct messages (DM) between users @@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`: - `TURN_USERNAME` - `TURN_PASSWORD` +For production deployments: +- `APP_BASE_URL` must be your public app origin and should use `https` +- `MEDIA_BASE_URL` should be a separate media origin for user uploads +- uploads and soundboard require R2/object storage to be configured + 3. Run app: ```bash @@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`. ## Authentik setup notes Create an Authentik OAuth2/OIDC provider + application and set: -- Redirect URI: `http://localhost:3000/auth/callback` +- Redirect URI: `${APP_BASE_URL}/auth/callback` - Scopes including at least: `openid profile email` If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match. @@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug. - `GET /channels/:channel_id/voice/ws` (WebSocket signaling) All endpoints except health and auth flow require the session cookie from successful login. +Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session. ## Notes @@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in Voice is implemented as browser-to-browser WebRTC audio with signaling in this server. For two users behind strict NAT/firewall, you may need TURN for reliable connectivity. The web UI remembers the last selected guild in browser local storage and auto-selects it on reload. +User uploads are served from the configured media origin, not from `/static`. Mic filter modes in the UI: - `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset) diff --git a/desktop/index.html b/desktop/index.html index ed754ec..1684077 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -1,3 +1,4 @@ + @@ -5,12 +6,9 @@ Chattz - - - - + - + @@ -116,8 +114,7 @@
@@ -144,6 +141,10 @@
+ + @@ -265,7 +266,17 @@
+ + - \ No newline at end of file + diff --git a/desktop/preload.js b/desktop/preload.js index dfcc665..f43670e 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,18 +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), - getConfig: () => ipcRenderer.invoke('get-config'), - storageGet: (key) => ipcRenderer.invoke('storage-get', key), - storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), - storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), getUpdateState: () => ipcRenderer.invoke('get-update-state'), 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) => ipcRenderer.on('update-state', (event, state) => callback(state)), - onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), - onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), - onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) + onUpdateState: (callback) => onIpc('update-state', callback), + onUpdateAvailable: (callback) => onIpc('update-available', callback), + onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback), + onUpdateError: (callback) => onIpc('update-error', callback) }); diff --git a/desktop/styles.css b/desktop/styles.css deleted file mode 100644 index fda1104..0000000 --- a/desktop/styles.css +++ /dev/null @@ -1,1629 +0,0 @@ -:root { - /* ── Backgrounds ─────────────────────────────────────────── */ - --bg-darker: #16171b; - --bg-sidebar: #1e2025; - --bg-main: #23252b; - --bg-secondary: #1a1c21; - --bg-tertiary: #111216; - --bg-modifier-selected: rgba(99, 102, 241, 0.15); - --bg-modifier-hover: rgba(255, 255, 255, 0.04); - --bg-input: #2a2d35; - - /* ── Text ────────────────────────────────────────────────── */ - --text-normal: #c9cdd4; - --text-muted: #7c818a; - --text-strong: #eef0f4; - --text-link: #818cf8; - - /* ── Accents ─────────────────────────────────────────────── */ - --brand: #6366f1; - --brand-hover: #4f46e5; - --brand-gradient: linear-gradient(135deg, #6366f1, #a855f7); - --brand-glow: rgba(99, 102, 241, 0.35); - --green: #22c55e; - --danger: #ef4444; - --yellow: #f59e0b; - - /* ── Misc ────────────────────────────────────────────────── */ - --font-main: "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, - sans-serif; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 20px; - --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Reset & Base */ -/* ═══════════════════════════════════════════════════════════ */ -* { - box-sizing: border-box; -} - -body { - margin: 0; - height: 100vh; - height: 100dvh; - background: var(--bg-darker); - color: var(--text-normal); - font-family: var(--font-main); - overflow: hidden; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ── Scrollbars ────────────────────────────────────────────── */ -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); - border-radius: 100px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.14); -} - -/* ── Base Elements ─────────────────────────────────────────── */ -button { - border: 0; - cursor: pointer; - transition: all var(--transition); - background: none; - color: inherit; - font: inherit; - padding: 0; -} - -input, -select { - border: 0; - outline: none; - background: var(--bg-input); - color: var(--text-normal); - font: inherit; -} - -.hidden { - display: none !important; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Auth Screen */ -/* ═══════════════════════════════════════════════════════════ */ -.auth-screen { - display: grid; - place-items: center; - height: 100vh; - /* Subtle animated gradient background */ - background: linear-gradient(135deg, #0f0c29, #16171b 40%, #1a1c2e 70%, #0f0c29); - background-size: 400% 400%; - animation: gradientShift 12s ease infinite; -} - -@keyframes gradientShift { - - 0%, - 100% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } -} - -.auth-card { - width: min(440px, 92vw); - background: rgba(30, 32, 37, 0.75); - backdrop-filter: blur(24px) saturate(1.4); - -webkit-backdrop-filter: blur(24px) saturate(1.4); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: var(--radius-xl); - padding: 40px 36px; - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5), 0 0 80px rgba(99, 102, 241, 0.08); - text-align: center; -} - -.brand-large { - width: 80px; - height: 80px; - background: var(--brand-gradient); - color: #fff; - border-radius: 22px; - display: grid; - place-items: center; - font-size: 38px; - font-weight: 800; - margin: 0 auto 24px; - box-shadow: 0 4px 24px var(--brand-glow); - transition: transform var(--transition), box-shadow var(--transition); -} - -.brand-large:hover { - transform: scale(1.05); - box-shadow: 0 6px 32px var(--brand-glow); -} - -.auth-card h1 { - margin: 0 0 8px; - color: var(--text-strong); - font-weight: 800; - font-size: 28px; - letter-spacing: -0.5px; -} - -.auth-card p { - margin: 0 0 28px; - color: var(--text-muted); - font-size: 15px; -} - -#login-btn { - width: 100%; - background: var(--brand-gradient); - color: #fff; - padding: 14px; - border-radius: var(--radius-md); - font-weight: 600; - font-size: 16px; - letter-spacing: 0.2px; - box-shadow: 0 2px 16px var(--brand-glow); - transition: all var(--transition); -} - -#login-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 24px var(--brand-glow); - filter: brightness(1.08); -} - -#login-btn:active { - transform: translateY(0); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Main Layout */ -/* ═══════════════════════════════════════════════════════════ */ -.shell { - height: 100vh; - height: 100dvh; - display: grid; - grid-template-columns: 72px 248px 1fr 248px; - background: var(--bg-main); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Server Rail */ -/* ═══════════════════════════════════════════════════════════ */ -.server-rail { - background: var(--bg-darker); - display: flex; - flex-direction: column; - align-items: center; - padding: 12px 0; - gap: 8px; - overflow-y: auto; - scrollbar-width: none; -} - -.server-rail::-webkit-scrollbar { - display: none; -} - -.guild-list { - display: flex; - flex-direction: column; - gap: 8px; - padding: 6px 0; -} - -.brand, -.guild-pill { - width: 48px; - height: 48px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition); - position: relative; - background: var(--bg-sidebar); -} - -.brand { - background: var(--brand-gradient); - color: #fff; - border-radius: 16px; - margin-bottom: 2px; - box-shadow: 0 2px 12px var(--brand-glow); -} - -.brand:hover { - border-radius: 16px; - box-shadow: 0 4px 20px var(--brand-glow); -} - -.separator { - width: 32px; - height: 2px; - background: rgba(255, 255, 255, 0.06); - margin-bottom: 2px; - border-radius: 1px; -} - -.guild-pill:hover, -.guild-pill.active { - border-radius: 16px; - background: var(--brand); - color: #fff; - box-shadow: 0 2px 12px var(--brand-glow); -} - -.guild-pill::before { - content: ""; - position: absolute; - left: -12px; - width: 4px; - height: 0; - background: #fff; - border-radius: 0 4px 4px 0; - transition: all var(--transition); -} - -.guild-pill:hover::before { - height: 20px; -} - -.guild-pill.active::before { - height: 40px; -} - -.action-pill { - color: var(--green); -} - -.action-pill:hover { - background: var(--green); - color: #fff; - box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Channel Sidebar */ -/* ═══════════════════════════════════════════════════════════ */ -.channel-sidebar { - background: var(--bg-sidebar); - display: flex; - flex-direction: column; -} - -.sidebar-header { - padding: 0 16px; - height: 48px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); - cursor: pointer; - transition: background-color var(--transition); -} - -.sidebar-header:hover { - background: var(--bg-modifier-hover); -} - -.sidebar-header h2 { - margin: 0; - font-size: 15px; - font-weight: 700; - color: var(--text-strong); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -0.2px; -} - -.sidebar-header-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.header-action-btn { - width: 28px; - height: 28px; - border-radius: var(--radius-sm); - display: grid; - place-items: center; - color: var(--text-muted); - transition: all var(--transition); -} - -.header-action-btn:hover { - background: var(--bg-modifier-hover); - color: var(--text-strong); -} - -.copied-badge { - background: var(--brand); - color: #fff; - font-size: 11px; - font-weight: 700; - padding: 2px 6px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.4px; - animation: fadeInOut 2s ease forwards; - pointer-events: none; - white-space: nowrap; - order: -1; - margin-right: 4px; -} - -@keyframes fadeInOut { - 0% { - opacity: 0; - transform: translateY(4px); - } - - 15% { - opacity: 1; - transform: translateY(0); - } - - 85% { - opacity: 1; - transform: translateY(0); - } - - 100% { - opacity: 0; - transform: translateY(-4px); - } -} - -.header-action-btn i { - width: 16px; - height: 16px; -} - -.sidebar-scroll { - flex: 1; - overflow-y: auto; - padding-top: 12px; -} - -.sidebar-group { - margin-bottom: 20px; -} - -.group-title { - padding: 0 8px 0 2px; - display: flex; - align-items: center; - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: color var(--transition); -} - -.group-title:hover { - color: var(--text-normal); -} - -.group-toggle { - width: 12px; - height: 12px; - margin-right: 2px; -} - -.group-title span { - flex: 1; -} - -.add-btn { - width: 20px; - height: 20px; - border-radius: var(--radius-sm); - opacity: 0.5; - transition: opacity var(--transition), background-color var(--transition), - transform var(--transition); - display: grid; - place-items: center; -} - -.add-btn:hover { - opacity: 1; - background: var(--bg-modifier-hover); - transform: scale(1.1); -} - -.add-btn i { - width: 16px; - height: 16px; -} - -.channel-list { - padding: 0 8px; - display: flex; - flex-direction: column; - gap: 2px; -} - -.channel-row { - display: flex; - align-items: center; - padding: 7px 10px; - border-radius: var(--radius-sm); - color: var(--text-muted); - font-weight: 500; - gap: 8px; - transition: all var(--transition); - border-left: 3px solid transparent; -} - -.channel-row:hover { - background: var(--bg-modifier-hover); - color: var(--text-normal); -} - -.channel-row.active { - background: var(--bg-modifier-selected); - color: var(--text-strong); - border-left-color: var(--brand); -} - -.channel-row i { - width: 20px; - height: 20px; - opacity: 0.5; -} - -.channel-row.active i { - opacity: 0.9; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Sidebar Footer */ -/* ═══════════════════════════════════════════════════════════ */ -.sidebar-footer { - background: var(--bg-secondary); - padding: 0; -} - -.user-panel { - padding: 8px 10px; - display: flex; - align-items: center; - gap: 10px; - height: 52px; - transition: background-color var(--transition); - border-radius: var(--radius-sm); - margin: 2px; -} - -.user-panel:hover { - background: var(--bg-modifier-hover); -} - -.avatar-wrapper { - position: relative; -} - -.avatar { - width: 32px; - height: 32px; - border-radius: 50%; - background: var(--brand-gradient); - color: #fff; - display: grid; - place-items: center; - font-weight: 700; - font-size: 13px; -} - -.status-dot { - position: absolute; - bottom: -2px; - right: -2px; - width: 14px; - height: 14px; - border-radius: 50%; - border: 3px solid var(--bg-secondary); -} - -.status-dot.online { - background: var(--green); - box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); -} - -.status-dot.idle { - background: var(--yellow); - box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); -} - -.voice-muted-icon { - width: 14px; - height: 14px; - color: var(--danger); - margin-left: 6px; - vertical-align: text-bottom; -} - -.user-volume-control { - width: 100%; - display: none; - /* Hidden by default */ - align-items: center; - gap: 8px; - padding: 4px 8px 4px 28px; - margin-top: 2px; - box-sizing: border-box; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - overflow: hidden; -} - -.user-volume-control.show-volume { - display: flex; - /* Show when active */ -} - -.volume-slider { - -webkit-appearance: none; - appearance: none; - background: var(--bg-darker); - border-radius: 2px; - outline: none; - flex: 1; - height: 4px; - cursor: pointer; - min-width: 0; -} - -.vol-pct { - font-size: 10px; - opacity: 0.6; - min-width: 30px; - text-align: right; -} - -.volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--brand); - cursor: pointer; - box-shadow: 0 0 4px var(--brand-glow); -} - -.volume-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--brand); - cursor: pointer; - box-shadow: 0 0 4px var(--brand-glow); -} - -.update-notifier { - display: flex; - align-items: center; - margin-right: 8px; -} - -.update-notifier button { - background: none; - border: none; - padding: 4px; - color: var(--brand); - cursor: pointer; - transition: transform 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.update-notifier button:hover { - transform: scale(1.2); - color: var(--brand-glow); -} - -.update-notifier button i { - width: 16px; - height: 16px; -} - - -.user-info { - flex: 1; - min-width: 0; -} - -.display-name { - font-size: 14px; - font-weight: 600; - color: var(--text-strong); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.user-status { - font-size: 12px; - color: var(--text-muted); -} - -.user-actions { - display: flex; - gap: 2px; -} - -.user-actions button { - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - display: grid; - place-items: center; - color: var(--text-muted); - transition: all var(--transition); -} - -.user-actions button:hover { - background: var(--bg-modifier-hover); - color: var(--text-strong); -} - -.user-actions i { - width: 20px; - height: 20px; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Voice Connection */ -/* ═══════════════════════════════════════════════════════════ */ -.voice-connection { - padding: 10px 12px; - background: var(--bg-secondary); - border-bottom: 1px solid rgba(255, 255, 255, 0.03); -} - -.vc-info { - display: flex; - align-items: center; - gap: 8px; -} - -.vc-icon { - color: var(--green); - width: 20px; - filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.4)); -} - -.vc-text { - flex: 1; - display: flex; - flex-direction: column; -} - -.vc-status { - color: var(--green); - font-size: 14px; - font-weight: 700; -} - -.vc-name { - color: var(--text-muted); - font-size: 12px; -} - -.vc-actions { - display: flex; - gap: 4px; -} - -.vc-actions button { - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - color: var(--text-muted); - display: grid; - place-items: center; - transition: all var(--transition); -} - -.vc-actions button:hover { - background: var(--bg-modifier-hover); - color: var(--text-strong); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Video Grid */ -/* ═══════════════════════════════════════════════════════════ */ -.video-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 16px; - padding: 16px; - background: var(--bg-tertiary); - flex: 1; - min-height: 0; - overflow-y: auto; -} - -.video-grid.hidden { - display: none; -} - -.video-item { - position: relative; - aspect-ratio: 16 / 9; - background: #000; - border-radius: var(--radius-lg); - overflow: hidden; - transition: all var(--transition); - cursor: pointer; - z-index: 1; -} - -.video-item:hover { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); - z-index: 2; -} - -.video-item video { - width: 100%; - height: 100%; - object-fit: contain; - background: #000; - pointer-events: none; -} - -/* Fullscreen Feed Support */ -.video-grid.has-fullscreen { - display: flex; - padding: 0; - overflow: hidden; - position: absolute; - top: 48px; - /* Below chat-header */ - left: 0; - right: 0; - bottom: 0; - z-index: 100; - background: var(--bg-main); -} - -.video-grid.has-fullscreen .video-item { - display: none; -} - -.video-grid.has-fullscreen .video-item.fullscreen { - display: block; - flex: 1; - width: 100%; - height: 100%; - border-radius: 0; - cursor: zoom-out; - z-index: 10; - aspect-ratio: auto; -} - -.video-item .video-label { - position: absolute; - bottom: 8px; - left: 8px; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - color: #fff; - padding: 3px 10px; - border-radius: 100px; - font-size: 12px; - font-weight: 500; - pointer-events: none; - z-index: 5; -} - -/* Voice Activity */ -.voice-speaking .avatar { - box-shadow: 0 0 0 2px var(--green), 0 0 8px rgba(34, 197, 94, 0.3); -} - -.video-item.speaking { - box-shadow: 0 0 0 3px var(--green), 0 0 16px rgba(34, 197, 94, 0.25); -} - -.video-item.speaking .video-label { - background: var(--green); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Chat Input */ -/* ═══════════════════════════════════════════════════════════ */ -.chat-input-wrapper { - padding: 0 16px 24px; -} - -.message-form { - background: var(--bg-input); - border-radius: var(--radius-md); - padding: 2px 12px; - display: flex; - align-items: center; - gap: 12px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); -} - -.input-action-btn { - color: var(--text-muted); - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); -} - -.input-action-btn:hover { - color: var(--text-normal); - background: var(--bg-modifier-hover); -} - -.message-form input { - flex: 1; - background: none; - border: 0; - padding: 12px 0; - font-size: 15px; - color: var(--text-normal); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* GIF Picker Modal */ -/* ═══════════════════════════════════════════════════════════ */ -.gif-modal { - width: min(500px, 95vw) !important; - max-height: 600px; - display: flex; - flex-direction: column; - padding: 0 !important; - overflow: hidden; -} - -.gif-modal .modal-header { - padding: 16px 20px; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -.gif-modal .modal-header h2 { - margin: 0; - font-size: 18px; -} - -.gif-search-wrapper { - padding: 16px 20px; - position: relative; -} - -.gif-search-wrapper input { - width: 100%; - background: var(--bg-darker); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: var(--radius-md); - padding: 12px 14px 12px 40px; - color: #fff; - font-size: 14px; - transition: border-color var(--transition); -} - -.gif-search-wrapper input:focus { - border-color: var(--brand); -} - -.gif-search-wrapper .search-icon { - position: absolute; - left: 32px; - top: 50%; - transform: translateY(-50%); - width: 18px; - height: 18px; - color: var(--text-muted); -} - -.gif-results-grid { - flex: 1; - overflow-y: auto; - padding: 0 20px 20px; - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-auto-rows: min-content; - gap: 8px; - min-height: 300px; -} - -.gif-item { - border-radius: var(--radius-sm); - overflow: hidden; - cursor: pointer; - background: var(--bg-sidebar); - width: 100%; - aspect-ratio: 16 / 9; - transition: transform var(--transition); -} - -.gif-item:hover { - transform: scale(1.02); - filter: brightness(1.1); -} - -.gif-item img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.gif-loading { - grid-column: 1 / -1; - text-align: center; - padding: 40px; - color: var(--text-muted); -} - -/* ── GIF in messages ────────────────────────────────────────── */ -.msg-gif { - margin-top: 8px; - max-width: 100%; - max-height: 300px; - border-radius: var(--radius-md); - overflow: hidden; - display: block; -} - -.msg-gif img { - max-width: 100%; - max-height: 300px; - display: block; - object-fit: contain; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Sound Board */ -/* ═══════════════════════════════════════════════════════════ */ -.soundboard-container { - padding: 12px; - background: var(--bg-secondary); - border-top: 1px solid rgba(255, 255, 255, 0.03); - display: flex; - flex-direction: column; - gap: 8px; -} - -.soundboard-header { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.4px; - color: var(--text-muted); -} - -.soundboard-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); - gap: 8px; - max-height: 200px; - overflow-y: auto; -} - -.sound-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 10px 8px; - background: var(--bg-darker); - border-radius: var(--radius-md); - cursor: pointer; - transition: all var(--transition); - border: 1px solid transparent; - position: relative; -} - -.sound-item:hover { - background: var(--bg-modifier-hover); - transform: translateY(-2px); - border-color: rgba(255, 255, 255, 0.06); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.sound-item:active { - transform: scale(0.95); -} - -.sound-delete { - position: absolute; - top: 4px; - right: 4px; - width: 18px; - height: 18px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.6); - color: var(--text-muted); - display: grid; - place-items: center; - opacity: 0; - transition: all var(--transition); - z-index: 2; -} - -.sound-delete:hover { - background: var(--danger); - color: #fff; - transform: scale(1.1); -} - -.sound-item:hover .sound-delete { - opacity: 1; -} - -.sound-icon { - font-size: 24px; - display: block; - object-fit: contain; -} - -.sound-name { - font-size: 11px; - text-align: center; - color: var(--text-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; -} - -.btn-add-sound { - font-size: 12px; - color: var(--brand); - cursor: pointer; - transition: color var(--transition); -} - -.btn-add-sound:hover { - color: var(--text-link); - text-decoration: underline; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Chat Pane */ -/* ═══════════════════════════════════════════════════════════ */ -.chat-pane { - position: relative; - display: flex; - flex-direction: column; - background: var(--bg-main); - min-width: 0; - min-height: 0; -} - -.chat-header { - height: 48px; - padding: 0 16px; - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); -} - -.header-icon { - color: var(--text-muted); - width: 24px; -} - -.chat-header h3 { - margin: 0; - font-size: 16px; - font-weight: 700; - color: var(--text-strong); - letter-spacing: -0.2px; -} - -.message-list { - flex: 1; - overflow-y: scroll; - padding: 16px 0; -} - -.msg { - padding: 4px 16px; - display: flex; - gap: 16px; - margin-top: 1.0625rem; - border-radius: var(--radius-sm); - transition: background-color var(--transition); - border-left: 3px solid transparent; -} - -.msg:hover { - background: rgba(255, 255, 255, 0.015); - border-left-color: rgba(99, 102, 241, 0.3); -} - -.msg-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--brand-gradient); - flex-shrink: 0; - display: grid; - place-items: center; - color: #fff; - font-weight: 600; - font-size: 15px; -} - -.msg-content { - flex: 1; - min-width: 0; -} - -.msg-header { - display: flex; - align-items: baseline; - gap: 8px; - margin-bottom: 4px; -} - -.msg-author { - font-weight: 600; - color: var(--text-strong); - cursor: pointer; - font-size: 1rem; - transition: color var(--transition); -} - -.msg-author:hover { - color: var(--text-link); - text-decoration: underline; -} - -.msg-time { - font-size: 11px; - color: var(--text-muted); -} - -.msg-body { - color: var(--text-normal); - line-height: 1.45rem; - white-space: pre-wrap; - word-wrap: break-word; -} - -.msg-grouped { - margin-top: 0; - padding-top: 0; - padding-bottom: 0; -} - -.msg-grouped .msg-avatar, -.msg-grouped .msg-header { - display: none; -} - -.msg-grouped .msg-content { - padding-left: 56px; -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Chat Input */ -/* ═══════════════════════════════════════════════════════════ */ -.chat-input-wrapper { - padding: 0 16px 24px; - padding-bottom: clamp(24px, calc(24px + env(safe-area-inset-bottom)), 40px); -} - -.message-form { - background: var(--bg-input); - border-radius: var(--radius-lg); - padding: 12px 18px; - border: 1px solid rgba(255, 255, 255, 0.04); - transition: border-color var(--transition), box-shadow var(--transition); -} - -.message-form:focus-within { - border-color: rgba(99, 102, 241, 0.4); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -.message-form input { - width: 100%; - background: transparent; - color: var(--text-normal); - font-size: 15px; -} - -.message-form input::placeholder { - color: var(--text-muted); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Member List / Utility Sidebar */ -/* ═══════════════════════════════════════════════════════════ */ -.utility-sidebar { - background: var(--bg-sidebar); - display: flex; - flex-direction: column; -} - -.member-list-wrapper { - flex: 1; - overflow-y: auto; - padding: 12px 8px; -} - -.member-row { - display: flex; - align-items: center; - gap: 12px; - padding: 7px 10px; - border-radius: var(--radius-sm); - color: var(--text-muted); - cursor: pointer; - transition: all var(--transition); -} - -.member-row:hover { - background: var(--bg-modifier-hover); - color: var(--text-normal); -} - -.member-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - background: var(--brand-gradient); - color: #fff; - display: grid; - place-items: center; - font-size: 13px; - font-weight: 600; - flex-shrink: 0; -} - -.member-name { - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Presence Badges ───────────────────────────────────────── */ -.avatar-wrapper { - position: relative; - display: inline-block; -} - -.status-badge { - position: absolute; - bottom: -2px; - right: -2px; - width: 12px; - height: 12px; - border-radius: 50%; - border: 2px solid var(--bg-sidebar); - background: #747f8d; -} - -.status-badge.online { - background: var(--green); - box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); -} - -.member-avatar, -.msg-avatar { - position: relative; -} - -.status-dot { - position: absolute; - bottom: 0; - right: 0; - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid var(--bg-sidebar); - background: #747f8d; -} - -.status-dot.online { - background: var(--green); - box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Modals */ -/* ═══════════════════════════════════════════════════════════ */ -.modal-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - display: grid; - place-items: center; - z-index: 1000; -} - -.modal { - background: rgba(30, 32, 37, 0.92); - backdrop-filter: blur(20px) saturate(1.4); - -webkit-backdrop-filter: blur(20px) saturate(1.4); - border: 1px solid rgba(255, 255, 255, 0.06); - width: min(460px, 95vw); - border-radius: var(--radius-xl); - padding: 28px; - color: var(--text-normal); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); -} - -.modal h2 { - margin: 0 0 20px; - text-align: center; - color: var(--text-strong); - font-weight: 700; - font-size: 22px; - letter-spacing: -0.3px; -} - -.form-item { - margin-bottom: 20px; -} - -.form-item label { - display: block; - font-size: 11px; - font-weight: 700; - color: var(--text-muted); - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.form-item input { - width: 100%; - padding: 11px 14px; - border-radius: var(--radius-md); - background: var(--bg-darker); - border: 1px solid rgba(255, 255, 255, 0.04); - transition: border-color var(--transition), box-shadow var(--transition); -} - -.form-item input:focus { - border-color: rgba(99, 102, 241, 0.4); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -.modal-footer { - margin-top: 24px; - display: flex; - justify-content: flex-end; - gap: 12px; -} - -.cancel-btn { - padding: 10px 20px; - color: var(--text-muted); - font-weight: 500; - border-radius: var(--radius-md); - transition: all var(--transition); -} - -.cancel-btn:hover { - color: var(--text-strong); - background: var(--bg-modifier-hover); -} - -.submit-btn { - background: var(--brand-gradient); - color: #fff; - padding: 10px 24px; - border-radius: var(--radius-md); - font-weight: 600; - box-shadow: 0 2px 12px var(--brand-glow); - transition: all var(--transition); -} - -.submit-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 20px var(--brand-glow); - filter: brightness(1.06); -} - -.submit-btn:active { - transform: translateY(0); -} - -/* ── Radio Group (Channel Type) ────────────────────────────── */ -.radio-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.radio-item { - cursor: pointer; - position: relative; -} - -.radio-item input { - position: absolute; - opacity: 0; -} - -.radio-box { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: var(--bg-modifier-hover); - border-radius: var(--radius-md); - border: 1px solid transparent; - transition: all var(--transition); -} - -.radio-item input:checked+.radio-box { - background: var(--bg-modifier-selected); - border-color: rgba(99, 102, 241, 0.3); - color: var(--text-strong); -} - -.radio-box i { - width: 24px; - height: 24px; - color: var(--text-muted); -} - -.radio-text { - display: flex; - flex-direction: column; -} - -.radio-text strong { - font-size: 16px; -} - -.radio-text span { - font-size: 12px; - color: var(--text-muted); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Mobile Overlay */ -/* ═══════════════════════════════════════════════════════════ */ -.overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 90; - opacity: 1; - transition: opacity 0.2s ease; -} - -.overlay.hidden { - opacity: 0; - pointer-events: none; - visibility: hidden; -} - -/* ── Mobile Toggle Buttons ─────────────────────────────────── */ -.mobile-toggle-btn { - display: none; - width: 32px; - height: 32px; - align-items: center; - justify-content: center; - color: var(--text-normal); - background: transparent; - margin-right: 8px; -} - -.mobile-toggle-btn:hover { - background: var(--bg-modifier-hover); - border-radius: var(--radius-sm); -} - -/* ═══════════════════════════════════════════════════════════ */ -/* Responsive */ -/* ═══════════════════════════════════════════════════════════ */ -@media (max-width: 1100px) { - .shell { - grid-template-columns: 72px 248px 1fr; - } - - .utility-sidebar { - position: fixed; - top: 0; - bottom: 0; - right: 0; - width: 248px; - z-index: 100; - box-shadow: -2px 0 20px rgba(0, 0, 0, 0.5); - transform: translateX(100%); - transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); - } - - .utility-sidebar.active { - transform: translateX(0); - display: flex; - } - - .chat-header .mobile-toggle-btn { - display: flex; - } -} - -@media (max-width: 768px) { - .shell { - grid-template-columns: 1fr; - } - - .server-rail, - .channel-sidebar { - position: fixed; - top: 0; - bottom: 0; - z-index: 100; - transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); - } - - .server-rail { - left: 0; - width: 72px; - transform: translateX(-100%); - } - - .channel-sidebar { - left: 72px; - width: 248px; - transform: translateX(-320px); - box-shadow: 2px 0 20px rgba(0, 0, 0, 0.5); - } - - .shell.menu-open .server-rail { - transform: translateX(0); - } - - .shell.menu-open .channel-sidebar { - transform: translateX(0); - display: flex; - } - - .chat-header { - padding: 0 8px; - } -} \ No newline at end of file diff --git a/main.js b/main.js index 3a32c94..030c413 100644 --- a/main.js +++ b/main.js @@ -1,11 +1,11 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); -const fs = require('fs'); -const url = require('url'); +const packageJson = require('./package.json'); +const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000; const updateState = { - status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error + status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error info: null, error: null, }; @@ -19,41 +19,43 @@ function broadcastUpdateState() { } } -function getStorageFilePath() { - return path.join(app.getPath('userData'), 'renderer-storage.json'); -} - -function readPersistentStore() { - const filePath = getStorageFilePath(); - try { - if (!fs.existsSync(filePath)) return {}; - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (err) { - console.error('Failed to read persistent store', err); - return {}; - } -} - -function writePersistentStore(store) { - const filePath = getStorageFilePath(); - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); - } catch (err) { - console.error('Failed to write persistent store', err); - } -} - function isNewerVersionAvailable(info) { 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; @@ -83,9 +85,62 @@ async function runUpdateCheck(reason = 'manual') { } } +function installDownloadedUpdate() { + if (installInProgress) { + console.log('Update install already in progress'); + return; + } + + installInProgress = true; + updateState.status = 'installing'; + updateState.error = null; + broadcastUpdateState(); + + const wins = BrowserWindow.getAllWindows(); + for (const win of wins) { + if (win.isDestroyed()) continue; + try { + win.removeAllListeners('close'); + win.destroy(); + } catch (err) { + console.warn('Failed to destroy window before update install', err); + } + } + + setImmediate(() => { + try { + autoUpdater.quitAndInstall(false, true); + } catch (err) { + installInProgress = false; + updateState.status = 'error'; + updateState.error = err && err.message ? err.message : String(err); + broadcastUpdateState(); + console.error('quitAndInstall failed', err); + } + }); + + setTimeout(() => { + if (installInProgress) { + console.warn('Update install is still waiting for app shutdown'); + } + }, 15000); +} + +function startPeriodicUpdateChecks() { + if (periodicUpdateTimer || !app.isPackaged) { + return; + } + + periodicUpdateTimer = setInterval(() => { + void runUpdateCheck('periodic'); + }, PERIODIC_UPDATE_CHECK_INTERVAL_MS); +} + function createWindow() { - // Create a persistent session for chattz to keep the user logged in 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, @@ -94,7 +149,6 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies session: sess, preload: path.join(__dirname, 'desktop', 'preload.js') } @@ -103,32 +157,33 @@ function createWindow() { win.setAutoHideMenuBar(true); win.setMenuBarVisibility(true); - // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { - if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { - return true; - } - return false; + const origin = webContentsOrigin(webContents); + if (origin !== backendOrigin) return false; + return permission === 'media' || permission === 'clipboard-write'; }); sess.setPermissionRequestHandler((webContents, permission, callback) => { - if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { + const origin = webContentsOrigin(webContents); + if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) { callback(true); } else { callback(false); } }); - // Handle screen share requests 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) => { - // Provide the first screen source by default, or implement a picker window here if (sources && sources.length > 0) { - // We prefer a screen over a window if available, simple heuristic const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0]; callback({ video: screenSource, audio: 'loopback' }); } else { - callback(null); // Reject safely + callback(null); } }).catch(err => { console.error("Failed to get desktop sources for screen share", err); @@ -136,55 +191,9 @@ function createWindow() { }); }); - const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, ''); - const indexPath = path.join(__dirname, 'desktop', 'index.html'); - - const loadDesktopApp = (queryParams = '') => { - const options = {}; - if (queryParams) { - try { - options.query = Object.fromEntries(new URLSearchParams(queryParams)); - } catch (e) { - console.error("Failed to parse query params", e); - } - } - - // Use setImmediate to ensure the current navigation tick is cleared, - // which prevents ERR_ABORTED (-3) on some platforms when interrupting a redirect. - setImmediate(() => { - if (win.isDestroyed()) return; - win.loadFile(indexPath, options).catch((err) => { - // Ignore aborted errors as they often happen during fast redirects - if (err.toString().includes('-3') || err.code === -3) return; - console.error(`Failed to load desktop file: ${err}`); - }); - }); - }; - - // Intercept navigations/redirects that return to the backend with a token - // after the OAuth login flow. Two handlers are needed because: - // - will-navigate fires for client-side navigations (location.href, link clicks) - // - will-redirect fires for server-side 302 redirects (OAuth callback chain) - const interceptLoginRedirect = (event, navigatedUrl) => { - try { - const urlObj = new URL(navigatedUrl); - const backendObj = new URL(backendUrl); - if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { - if (urlObj.searchParams.has('token')) { - console.log("Detected login success redirect, returning to desktop UI..."); - event.preventDefault(); - loadDesktopApp(urlObj.search.slice(1)); - } - } - } catch (e) { - console.error(e); - } - }; - - win.webContents.on('will-navigate', interceptLoginRedirect); - win.webContents.on('will-redirect', interceptLoginRedirect); - - loadDesktopApp(); + 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 @@ -203,40 +212,11 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { - // Register IPC handlers once (before creating any windows) - ipcMain.handle('get-config', () => { - return { - backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') - }; - }); - ipcMain.handle('clipboard-write', (event, text) => { clipboard.writeText(text); return true; }); - ipcMain.handle('storage-get', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return null; - const store = readPersistentStore(); - return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; - }); - - ipcMain.handle('storage-set', (event, key, value) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - store[key] = value; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('storage-remove', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - delete store[key]; - writePersistentStore(store); - return true; - }); - ipcMain.handle('get-update-state', () => ({ ...updateState })); ipcMain.handle('check-for-updates-now', async () => { await runUpdateCheck('renderer-direct'); @@ -287,6 +267,7 @@ app.whenReady().then(() => { }); ipcMain.on('download-update', () => { + if (installInProgress) return; updateState.status = 'downloading'; updateState.error = null; broadcastUpdateState(); @@ -294,13 +275,14 @@ app.whenReady().then(() => { }); ipcMain.on('quit-and-install', () => { - autoUpdater.quitAndInstall(); + installDownloadedUpdate(); }); // Check once on startup setTimeout(() => { void runUpdateCheck('startup'); }, 5000); + startPeriodicUpdateChecks(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -314,3 +296,18 @@ app.on('window-all-closed', () => { app.quit(); } }); + +app.on('before-quit', () => { + if (periodicUpdateTimer) { + clearInterval(periodicUpdateTimer); + periodicUpdateTimer = null; + } +}); + +function safeOrigin(value) { + try { + return new URL(value).origin; + } catch (_) { + return null; + } +} diff --git a/package.json b/package.json index 6967283..f5c75b8 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,12 @@ }, "main": "main.js", "scripts": { - "start": "electron .", - "dev": "electron .", - "dist": "electron-builder --publish never", - "dist:linux": "electron-builder --linux --publish never", - "dist:win": "electron-builder --win --publish never" + "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", diff --git a/scripts/generate-html.js b/scripts/generate-html.js new file mode 100644 index 0000000..a773454 --- /dev/null +++ b/scripts/generate-html.js @@ -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 = ``; + +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: `
+ Desktop downloads: + Windows + Linux (RPM) +
`, + 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, + `\n${output}`, + 'utf8' + ); +} diff --git a/shared-html/index.template.html b/shared-html/index.template.html new file mode 100644 index 0000000..3425611 --- /dev/null +++ b/shared-html/index.template.html @@ -0,0 +1,279 @@ + + + + + + + Chattz + + + + + + +
+
+
C
+

Chattz

+

Sign in to open your servers.

+ + {{web_downloads}} +

+
+
+ + + + + + + + + + + + + + + + + + + diff --git a/src/auth.rs b/src/auth.rs index 667c64d..535953e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,33 +1,25 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use axum::{ Json, extract::{FromRef, FromRequestParts}, - http::{StatusCode, request::Parts}, + http::{HeaderMap, StatusCode, request::Parts}, response::{IntoResponse, Response}, }; -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; -use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; +use serde::Serialize; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{AppState, db}; -const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; -const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes -const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SessionClaims { - pub sub: String, - pub kind: String, // "access" or "refresh" - pub exp: usize, - pub iat: usize, -} +pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; +pub const SESSION_COOKIE: &str = "chattz_session"; +const SESSION_TTL_DAYS: i64 = 30; #[derive(Debug, Clone)] pub struct AuthUser { pub id: Uuid, + pub session_id: String, } #[derive(Debug)] @@ -44,6 +36,13 @@ impl ApiError { } } + pub fn forbidden(msg: &str) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: msg.to_string(), + } + } + pub fn bad_request(msg: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, @@ -57,6 +56,13 @@ impl ApiError { message: msg.to_string(), } } + + pub fn service_unavailable(msg: &str) -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + message: msg.to_string(), + } + } } #[derive(Serialize)] @@ -84,29 +90,25 @@ impl From for ApiError { impl FromRequestParts for AuthUser where - AppState: axum::extract::FromRef, + AppState: FromRef, S: Send + Sync, { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app = AppState::from_ref(state); + let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE) + .ok_or_else(|| ApiError::unauthorized("missing session cookie"))?; - let token = read_bearer_token(parts) - .or_else(|| read_query_token(parts)) - .ok_or_else(|| ApiError::unauthorized("missing jwt token"))?; - - let user_id = verify_session(&token, &app.settings.session_secret, "access") - .map_err(|_| ApiError::unauthorized("invalid or expired token"))?; - - let exists = db::user_exists(&app.db, user_id) + let user_id = db::touch_active_session(&app.db, &session_id) .await - .map_err(|_| ApiError::unauthorized("session user not found"))?; - if !exists { - return Err(ApiError::unauthorized("session user not found")); - } + .map_err(|_| ApiError::unauthorized("invalid or expired session"))? + .ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?; - Ok(Self { id: user_id }) + Ok(Self { + id: user_id, + session_id, + }) } } @@ -114,6 +116,14 @@ pub fn new_oauth_state() -> String { Uuid::new_v4().to_string() } +pub fn new_session_id() -> String { + Uuid::new_v4().simple().to_string() +} + +pub fn session_expiry() -> chrono::DateTime { + (Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset() +} + pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { format!( "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}", @@ -123,76 +133,32 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { } pub fn clear_oauth_state_cookie(secure: bool) -> String { + clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax") +} + +pub fn make_session_cookie(value: &str, secure: bool) -> String { format!( - "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", - name = OAUTH_STATE_COOKIE, + "{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}", + name = SESSION_COOKIE, + ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(), secure_flag = if secure { "; Secure" } else { "" } ) } -pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option { +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 { let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; raw.split(';').find_map(|pair| { let mut kv = pair.trim().splitn(2, '='); let key = kv.next()?; let value = kv.next()?; - (key == OAUTH_STATE_COOKIE).then(|| value.to_string()) + (key == cookie_name).then(|| value.to_string()) }) } -pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> { - let now = now_ts(); - - let access_claims = SessionClaims { - sub: user_id.to_string(), - kind: "access".to_string(), - iat: now as usize, - exp: (now + SESSION_TTL_SECS) as usize, - }; - - let refresh_claims = SessionClaims { - sub: user_id.to_string(), - kind: "refresh".to_string(), - iat: now as usize, - exp: (now + REFRESH_TTL_SECS) as usize, - }; - - let access_token = encode( - &Header::default(), - &access_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to encode access token")?; - - let refresh_token = encode( - &Header::default(), - &refresh_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to encode refresh token")?; - - Ok((access_token, refresh_token)) -} - -pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result { - let mut validation = Validation::default(); - validation.validate_exp = true; - - let data = decode::( - token, - &DecodingKey::from_secret(secret.as_bytes()), - &validation, - ) - .context("failed to decode session token")?; - - if data.claims.kind != expected_kind { - return Err(anyhow!("invalid token kind")); - } - - let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?; - Ok(user_id) -} - pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) -> Result<()> { let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?; if expected != query_state { @@ -201,37 +167,113 @@ pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) Ok(()) } -fn read_bearer_token(parts: &Parts) -> Option { - let raw = parts - .headers - .get(axum::http::header::AUTHORIZATION)? - .to_str() - .ok()?; - if raw.starts_with("Bearer ") { - Some(raw["Bearer ".len()..].trim().to_string()) - } else { - None +pub fn user_agent_hash(headers: &HeaderMap) -> Option { + header_hash(headers, axum::http::header::USER_AGENT.as_str()) +} + +pub fn ip_hash(headers: &HeaderMap) -> Option { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|raw| raw.split(',').next().map(str::trim)) + .filter(|value| !value.is_empty()) + .map(hash_string) + .or_else(|| header_hash(headers, "x-real-ip")) +} + +pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool { + headers + .get(axum::http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(|origin| origin == expected_origin) + .unwrap_or(false) +} + +fn header_hash(headers: &HeaderMap, header_name: &str) -> Option { + headers + .get(header_name) + .and_then(|v| v.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .map(hash_string) +} + +fn hash_string(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push(nibble_to_hex(byte >> 4)); + out.push(nibble_to_hex(byte & 0x0f)); + } + out +} + +fn nibble_to_hex(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => unreachable!(), } } -fn read_query_token(parts: &Parts) -> Option { - let query = parts.uri.query()?; +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 { "" } + ) +} - // Simple query param parsing without pulling in url::Url overhead - for pair in query.split('&') { - let mut kv = pair.splitn(2, '='); - let key = kv.next()?; - let value = kv.next()?; - if key == "token" { - return Some(value.to_string()); - } +#[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" + ); } - None -} -fn now_ts() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() + #[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")); + } } diff --git a/src/chat.rs b/src/chat.rs index 8b59bf6..565f08b 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,27 +1,30 @@ -use crate::AppState; +use std::collections::HashMap; + use axum::extract::ws::{Message, WebSocket}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use tokio::sync::{RwLock, mpsc}; use uuid::Uuid; +use crate::{AppState, db}; + #[derive(Clone)] pub struct ChatClient { tx: mpsc::UnboundedSender, is_idle: bool, } -#[derive(Serialize, Clone)] +#[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 -> client - clients: RwLock>, + // user_id -> connection_id -> client + clients: RwLock>>, } #[derive(Serialize, Clone)] @@ -45,85 +48,113 @@ pub enum ServerEvent { #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientEvent { - // Currently no interactive client events for the general chat WS Ping, SetIdleStatus { is_idle: bool }, } impl ChatHub { - pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender) { + pub async fn add_client( + &self, + user_id: Uuid, + connection_id: Uuid, + tx: mpsc::UnboundedSender, + ) -> Option { let mut clients = self.clients.write().await; - clients.insert(user_id, ChatClient { tx, is_idle: false }); - } + let previous = aggregate_presence(clients.get(&user_id), user_id); - pub async fn remove_client(&self, user_id: Uuid) { - let mut clients = self.clients.write().await; - clients.remove(&user_id); - } - - pub async fn get_online_users(&self) -> Vec { - let clients = self.clients.read().await; 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 { + 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 { + let clients = self.clients.read().await; + visible_user_ids .iter() - .map(|(id, client)| OnlineUser { - user_id: *id, - idle: client.is_idle, - }) + .filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id)) + .filter(|presence| presence.online) .collect() } - pub async fn broadcast_all(&self, event: ServerEvent) { - let clients = self.clients.read().await; - for client in clients.values() { - let _ = client.tx.send(event.clone()); - } - } - - pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { - let clients = self.clients.read().await; - if let Some(client) = clients.get(&user_id) { - let _ = client.tx.send(event); - } - } - pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) { let clients = self.clients.read().await; for user_id in user_ids { - if let Some(client) = clients.get(&user_id) { + if let Some(connections) = clients.get(&user_id) { + for client in connections.values() { + let _ = client.tx.send(event.clone()); + } + } + } + } + + pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { + let clients = self.clients.read().await; + if let Some(connections) = clients.get(&user_id) { + for client in connections.values() { let _ = client.tx.send(event.clone()); } } } - pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) { + pub async fn set_idle_status( + &self, + user_id: Uuid, + connection_id: Uuid, + is_idle: bool, + ) -> Option { + let mut clients = self.clients.write().await; + let previous = aggregate_presence(clients.get(&user_id), user_id); + + if let Some(connections) = clients.get_mut(&user_id) + && let Some(client) = connections.get_mut(&connection_id) { - let mut clients = self.clients.write().await; - if let Some(client) = clients.get_mut(&user_id) { - client.is_idle = is_idle; - } + client.is_idle = is_idle; } - self.broadcast_all(ServerEvent::UserPresence { - user_id, - online: true, - idle: is_idle, - }) - .await; + + 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::(); + let connection_id = Uuid::new_v4(); - state.chat.add_client(user_id, tx).await; - state - .chat - .broadcast_all(ServerEvent::UserPresence { - user_id, - online: true, - idle: false, - }) - .await; + if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await { + if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await { + state + .chat + .broadcast_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 { @@ -142,8 +173,24 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { Message::Text(text) => { if let Ok(ClientEvent::SetIdleStatus { is_idle }) = serde_json::from_str::(&text) + && let Some(presence) = state + .chat + .set_idle_status(user_id, connection_id, is_idle) + .await + && let Ok(visible_user_ids) = + db::list_visible_user_ids(&state.db, user_id).await { - state.chat.set_idle_status(user_id, is_idle).await; + state + .chat + .broadcast_to_many( + visible_user_ids, + ServerEvent::UserPresence { + user_id: presence.user_id, + online: presence.online, + idle: presence.idle, + }, + ) + .await; } } _ => {} @@ -151,13 +198,157 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { } send_task.abort(); - state.chat.remove_client(user_id).await; - state - .chat - .broadcast_all(ServerEvent::UserPresence { - user_id, + 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>, + user_id: Uuid, +) -> Option { + 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, current: Option) -> Option { + 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, - }) - .await; + }), + (_, 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" + )); + } } diff --git a/src/config.rs b/src/config.rs index 8f55aa0..fbe7ecb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,11 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; +use reqwest::Url; #[derive(Clone, Debug)] pub struct Settings { pub port: u16, + pub app_base_url: String, + pub app_origin: String, pub database_url: String, pub oidc_client_id: String, pub oidc_client_secret: String, @@ -11,8 +14,7 @@ pub struct Settings { pub oidc_userinfo_url: String, pub oidc_redirect_url: String, pub oidc_scopes: String, - pub session_secret: String, - pub cookie_secure: bool, + pub session_cookie_secure: bool, pub stun_urls: Vec, pub turn_urls: Vec, pub turn_username: Option, @@ -31,11 +33,17 @@ pub struct MediaSettings { impl Settings { pub fn from_env() -> Result { + let app_base_url = required("APP_BASE_URL")?; + let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?; + let app_origin = origin_from_url(&app_url)?; + Ok(Self { port: std::env::var("PORT") .unwrap_or_else(|_| "3000".into()) .parse() .context("PORT must be a valid u16")?, + app_base_url: trim_trailing_slash(&app_base_url), + app_origin, database_url: required("DATABASE_URL")?, oidc_client_id: required("OIDC_CLIENT_ID")?, oidc_client_secret: required("OIDC_CLIENT_SECRET")?, @@ -45,11 +53,7 @@ impl Settings { oidc_redirect_url: required("OIDC_REDIRECT_URL")?, oidc_scopes: std::env::var("OIDC_SCOPES") .unwrap_or_else(|_| "openid profile email".to_string()), - session_secret: required("SESSION_SECRET")?, - cookie_secure: std::env::var("COOKIE_SECURE") - .unwrap_or_else(|_| "false".into()) - .parse() - .context("COOKIE_SECURE must be true/false")?, + session_cookie_secure: requires_secure_cookie(&app_url)?, stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"), turn_urls: parse_csv_env("TURN_URLS", ""), turn_username: optional("TURN_USERNAME"), @@ -65,7 +69,7 @@ impl MediaSettings { 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("R2_PUBLIC_BASE_URL"); + 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() @@ -81,7 +85,9 @@ impl MediaSettings { 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: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?, + public_base_url: trim_trailing_slash( + &public_base_url.context("missing env var MEDIA_BASE_URL")?, + ), })) } @@ -109,3 +115,105 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec { .map(ToString::to_string) .collect() } + +fn origin_from_url(url: &Url) -> Result { + 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 { + 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" + ); + } +} diff --git a/src/db.rs b/src/db.rs index 42d980b..c86dd8c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,17 @@ +use std::collections::HashSet; + use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - TransactionTrait, sea_query::OnConflict, + Statement, TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{ - attachments, channels, direct_messages, guild_members, guilds, invites, messages, + attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions, soundboard_sounds, users, }, models::{ @@ -26,6 +28,88 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result Ok(count > 0) } +pub async fn create_session( + db: &DatabaseConnection, + session_id: &str, + user_id: Uuid, + expires_at: chrono::DateTime, + user_agent_hash: Option, + ip_hash: Option, +) -> Result<()> { + sessions::Entity::insert(sessions::ActiveModel { + id: Set(session_id.to_string()), + user_id: Set(user_id), + expires_at: Set(expires_at), + created_at: Set(Utc::now().fixed_offset()), + last_seen_at: Set(Utc::now().fixed_offset()), + revoked_at: Set(None), + user_agent_hash: Set(user_agent_hash), + ip_hash: Set(ip_hash), + }) + .exec(db) + .await?; + + Ok(()) +} + +pub async fn touch_active_session( + db: &DatabaseConnection, + session_id: &str, +) -> Result> { + let session = sessions::Entity::find_by_id(session_id.to_string()) + .one(db) + .await?; + + let Some(session) = session else { + return Ok(None); + }; + + if session.revoked_at.is_some() || session.expires_at <= Utc::now().fixed_offset() { + return Ok(None); + } + + let user_id = session.user_id; + sessions::Entity::update_many() + .col_expr( + sessions::Column::LastSeenAt, + sea_orm::sea_query::Expr::value(Utc::now().fixed_offset()), + ) + .filter(sessions::Column::Id.eq(session_id.to_string())) + .exec(db) + .await?; + Ok(Some(user_id)) +} + +pub async fn revoke_session(db: &DatabaseConnection, session_id: &str) -> Result<()> { + if let Some(session) = sessions::Entity::find_by_id(session_id.to_string()) + .one(db) + .await? + { + sessions::Entity::update_many() + .col_expr( + sessions::Column::RevokedAt, + sea_orm::sea_query::Expr::value(Some(Utc::now().fixed_offset())), + ) + .filter(sessions::Column::Id.eq(session.id)) + .exec(db) + .await?; + } + + Ok(()) +} + +pub async fn cleanup_sessions(db: &DatabaseConnection) -> Result<()> { + sessions::Entity::delete_many() + .filter( + Condition::any() + .add(sessions::Column::ExpiresAt.lte(Utc::now().fixed_offset())) + .add(sessions::Column::RevokedAt.is_not_null()), + ) + .exec(db) + .await?; + Ok(()) +} + pub async fn upsert_user_from_oidc( db: &DatabaseConnection, oidc_sub: &str, @@ -106,18 +190,58 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res .collect()) } +pub async fn list_visible_user_ids(db: &DatabaseConnection, user_id: Uuid) -> Result> { + let guild_ids: Vec = guild_members::Entity::find() + .filter(guild_members::Column::UserId.eq(user_id)) + .select_only() + .column(guild_members::Column::GuildId) + .into_tuple() + .all(db) + .await?; + + let mut visible = HashSet::from([user_id]); + + if !guild_ids.is_empty() { + let guild_users = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.is_in(guild_ids)) + .all(db) + .await?; + visible.extend(guild_users.into_iter().map(|membership| membership.user_id)); + } + + let dm_rows = direct_messages::Entity::find() + .filter( + Condition::any() + .add(direct_messages::Column::SenderUserId.eq(user_id)) + .add(direct_messages::Column::RecipientUserId.eq(user_id)), + ) + .all(db) + .await?; + + for row in dm_rows { + if row.sender_user_id == user_id { + visible.insert(row.recipient_user_id); + } else { + visible.insert(row.sender_user_id); + } + } + + Ok(visible.into_iter().collect()) +} + pub async fn create_guild( db: &DatabaseConnection, owner_user_id: Uuid, name: &str, ) -> Result { + let txn = db.begin().await?; let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), owner_user_id: Set(owner_user_id), ..Default::default() }) - .exec_with_returning(db) + .exec_with_returning(&txn) .await?; guild_members::Entity::insert(guild_members::ActiveModel { @@ -133,17 +257,13 @@ pub async fn create_guild( .do_nothing() .to_owned(), ) - .exec(db) + .exec(&txn) .await?; + txn.commit().await?; Ok(map_guild(guild)) } -pub async fn get_guild_by_id(db: &DatabaseConnection, guild_id: Uuid) -> Result> { - let row = guilds::Entity::find_by_id(guild_id).one(db).await?; - Ok(row.map(map_guild)) -} - pub async fn is_guild_owner( db: &DatabaseConnection, guild_id: Uuid, @@ -197,7 +317,12 @@ pub async fn create_invite( pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result { let txn = db.begin().await?; - let invite = invites::Entity::find_by_id(code.to_string()) + let invite = invites::Entity::find() + .from_raw_sql(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#, + [code.into()], + )) .one(&txn) .await? .ok_or_else(|| anyhow!("invite not found"))?; @@ -797,7 +922,10 @@ pub async fn create_sound( created_by_user_id: Uuid, name: &str, icon: &str, - file_path: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, ) -> Result { let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel { id: Set(Uuid::new_v4()), @@ -805,7 +933,13 @@ pub async fn create_sound( created_by_user_id: Set(created_by_user_id), name: Set(name.to_string()), icon: Set(icon.to_string()), - file_path: Set(file_path.to_string()), + object_key: Set(Some(object_key.to_string())), + media_url: Set(media_url.to_string()), + mime_type: Set(Some(mime_type.to_string())), + size_bytes: Set(Some(size_bytes)), + // Keep the legacy column populated until every deployment has applied + // the nullable migration and old fallback paths are fully removed. + file_path: Set(Some(media_url.to_string())), ..Default::default() }) .exec_with_returning(db) @@ -819,6 +953,11 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result