diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80af3b7..6f17fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,16 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy + # The binary embeds frontend/dist/ at compile time (rust-embed), so the + # client must be built before any cargo compile step. + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build embedded frontend + run: npm ci && npm run build + working-directory: frontend - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets --locked -- -D warnings @@ -55,6 +65,16 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + # The binary embeds frontend/dist/ at compile time (rust-embed), so the + # client must be built before any cargo compile step. + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build embedded frontend + run: npm ci && npm run build + working-directory: frontend - uses: Swatinem/rust-cache@v2 - run: cargo test --locked diff --git a/.gitignore b/.gitignore index ab3f615..bd06b69 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,10 @@ target .env # Personal notes, not for commit -NOTES.txt +NOTES.* # Local dev infrastructure, not for commit docker-compose.yml + +# Claude Code skills (local, not for commit) +.claude/skills/ diff --git a/Cargo.lock b/Cargo.lock index 9cc8e05..8402123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,6 +250,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -310,16 +321,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -460,15 +461,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "envy" version = "0.4.2" @@ -516,12 +508,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -557,21 +543,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -997,11 +968,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1149,6 +1118,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1286,12 +1264,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1377,23 +1349,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nonempty" version = "0.7.0" @@ -1467,50 +1422,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking" version = "2.2.1" @@ -1875,7 +1792,9 @@ dependencies = [ "envy", "futures-util", "governor", + "infer", "reqwest", + "rust-embed", "serde", "serde_json", "sha2", @@ -1901,9 +1820,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", @@ -1912,7 +1829,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -1966,24 +1882,46 @@ dependencies = [ ] [[package]] -name = "rustc-hash" -version = "2.1.2" +name = "rust-embed" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] [[package]] -name = "rustix" -version = "1.1.4" +name = "rust-embed-impl" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "mime_guess", + "sha2", + "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustls" version = "0.23.38" @@ -1992,6 +1930,7 @@ checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2026,7 +1965,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -2102,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2335,9 +2274,9 @@ dependencies = [ "indexmap", "log", "memchr", - "native-tls", "once_cell", "percent-encoding", + "rustls", "serde", "serde_json", "sha2", @@ -2348,6 +2287,7 @@ dependencies = [ "tracing", "url", "uuid", + "webpki-roots 0.26.11", ] [[package]] @@ -2551,40 +2491,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -3213,6 +3119,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3295,17 +3219,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 7647d60..e2df63e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" license = "GPL-3.0-or-later" description = "A self-hosted chat platform POC in async Rust" +default-run = "relay" [dependencies] argon2 = { version = "0.5.3", features = ["std"] } @@ -14,11 +15,13 @@ dotenvy = "0.15.7" envy = "0.4.2" futures-util = "0.3.32" governor = "0.10.4" -reqwest = "0.13.2" +infer = "0.16" +reqwest = { version = "0.13.2", default-features = false, features = ["rustls"] } +rust-embed = { version = "8", features = ["mime-guess"] } serde = "1.0.228" serde_json = "1.0.149" sha2 = "0.10.8" -sqlx = {version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "macros", "migrate", "uuid", "chrono", "json"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "uuid", "chrono", "json"] } tokio = { version = "1.51.0", features = ["full"] } tokio-stream = { version = "0.1.17", features = ["sync"] } tokio-tungstenite = "0.29.0" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ea01b25 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +.PHONY: db seed backend backend-embedded frontend build lan-info + +# ── Development (localhost only) ────────────────────────────────────────────── +# +# Two terminals: +# Terminal 1: make backend (API on :3000) +# Terminal 2: make frontend (UI on :5173, hot-reload, /ws proxied to :3000) +# +# Visit http://localhost:5173 +# Do not use http://localhost:3000 for frontend testing in dev mode; that serves +# the embedded frontend from the last production-style build. +# +# ── Development (LAN — test from phones / other machines) ───────────────────── +# +# Same two-terminal setup, but Vite already listens on 0.0.0.0 so any device +# on the same network can reach it. The backend also binds 0.0.0.0:3000. +# +# Run `make lan-info` to print the URL to use on other devices. +# (Vite also prints it when it starts: look for the "Network:" line.) +# +# ── Production ──────────────────────────────────────────────────────────────── +# +# make build → target/release/relay +# +# The release binary is self-contained: the frontend is embedded at compile +# time via rust-embed. No Vite server, no static files directory needed. +# +# Visit http://:3000 from any device on the network. +# +# ───────────────────────────────────────────────────────────────────────────── + +# Start the development database (PostgreSQL via Docker). +# Only needed if you are not running Postgres locally already. +db: + docker compose up -d + +# Populate the database with test users, rooms, and messages. +# Runs migrations first, so this is also the first-time setup step. +# Safe to re-run: existing data is left untouched. +# +# admin / admin123 (admin) +# alice / password +# bob / password +# carol / password +seed: + cargo run --bin seed + +# Start the relay backend (debug build). Run in terminal 1. +# Depends on seed so migrations and test data are always present. +backend: seed + @echo "Backend API on http://localhost:3000. Use make frontend and open http://localhost:5173 for dev UI." + cargo run + +# Start the backend with the production-style embedded frontend on :3000. +# Use this only when you specifically want to test the embedded UI path. +backend-embedded: seed + cd frontend && npm run build + cargo run + +# Start the Vite dev server with hot-reload. Run in terminal 2. +# Proxies /ws to the backend on :3000. +frontend: + cd frontend && npm run dev + +# Production build: compile the frontend and embed it in the release binary. +# build.rs marks frontend/dist as a Cargo input, so Cargo rebuilds the binary +# when npm produces a new bundle. +# The resulting binary at target/release/relay needs no external files. +build: + cd frontend && npm run build + cargo build --release + +# Print the LAN URL to visit from other devices on the same network. +lan-info: + @LAN_IP=$$(ip -4 route get 1 2>/dev/null | awk '{print $$7; exit}' || ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $$1}'); \ + echo ""; \ + echo " Dev mode → http://$$LAN_IP:5173 (make backend + make frontend)"; \ + echo " Prod mode → http://$$LAN_IP:3000 (make build, then ./target/release/relay)"; \ + echo "" diff --git a/README.md b/README.md index 36618c5..9c53908 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A self-hosted real-time chat platform written in async Rust. ## Overview -Relay is a WebSocket chat server built on Axum, Tokio, and Postgres. Clients exchange a tagged JSON protocol over WebSocket; the server handles connection lifecycle, per-IP rate limiting, and graceful shutdown around it. Accounts, rooms, messages, attachments, reactions, and read state are persisted in Postgres; password verification uses Argon2id. Messages fan out to connected room members in real time. +Relay is a WebSocket chat server built on Axum, Tokio, and Postgres. Clients exchange a tagged JSON protocol over WebSocket; the server handles connection lifecycle, per-IP rate limiting, and graceful shutdown around it. Accounts, rooms, messages, attachments, reactions, and read state are persisted in Postgres; password verification uses Argon2id. Messages fan out to connected room members in real time. The binary also serves a bundled default web client (a Svelte + TypeScript frontend embedded at compile time), so a deployment is usable out of the box without a separate client. > **Developer documentation** — for a full map of how the system is threaded together (actors, per-connection tasks, the fan-out hub, the request lifecycle) plus reference catalogs for the wire protocol, channels, and data model, see [`docs/`](docs/), starting with [`docs/architecture.md`](docs/architecture.md). @@ -59,24 +59,35 @@ Deleting a user cascades to credentials, admin grant, and memberships but preser - DB-backed user accounts with Argon2id password verification - User CRUD: create (open-signup or admin-gated), look up, edit profile, delete, self password update, admin password reset - Admin model: bootstrap default admin, promote, demote, default-admin protection -- Rooms: visibility (public/discoverable), multi-owner membership, rename, public join/leave, request-to-join with approve/reject, invite with accept/decline +- Admin moderation: list every room (including private, non-discoverable), and read any room's message history regardless of membership +- Rooms: visibility (public/discoverable), multi-owner membership, rename, public join/leave, request-to-join with approve/reject (and requester self-cancel), invite with accept/decline, owner/admin removal of a member (kick) - Messaging: send, paginated history (newest-first keyset), reactions -- Attachments: resumable chunked upload/download with size + SHA-256 verification -- Real-time fan-out: live message delivery to connected room members, with dynamic and cross-session subscription and a lossy-with-resync delivery model +- Attachments: resumable chunked upload/download with size + SHA-256 verification, plus a server-side content-type policy (magic-byte sniff that corrects mislabeled types and rejects unsupported ones) +- Real-time fan-out: live message delivery to connected room members, with dynamic and cross-session subscription (and live unsubscribe when a member is kicked), and a lossy-with-resync delivery model - Read state: per-room read watermark, mark-read, and unread counts - Time-based reaper: ages out old messages, empty rooms, stale invites/requests, and abandoned uploads - Admin server lifecycle: in-app restart and shutdown, via a supervisor loop that re-initializes a fresh server pass without dropping the listen port +- Structured logging and tracing across the server (`tracing`), with a dedicated audit target for security-relevant events +- Bundled default web client: a Svelte + TypeScript frontend embedded in the binary at compile time (`rust-embed`), overridable with `FRONTEND_DIR` - Integration tests against real Postgres via `sqlx::test` ## Roadmap -- **Structured logging and tracing** — replace `println!` and the `// TODO - Logging` markers throughout the actors and session paths with structured events. - **TLS enforcement** — harden the release configuration so plain-text listeners can't be misconfigured. -- **A first-party client** — the server ships only a minimal browser test page; a real client would exercise the [client contract](docs/client-contract.md) end to end. +- **Admin moderation depth** — extend admin read-through (it covers message history today) to attachment downloads, and consider live subscription so admins can watch a room in real time, not just on reload. +- **First-party client polish** — the bundled Svelte client now covers auth, rooms, messaging, attachments, reactions, admin tooling, and theming; remaining work is hardening it against the full [client contract](docs/client-contract.md) (notification, offline catch-up edge cases) and broadening test coverage. ## Running locally -Requirements: Rust (edition 2024) and a running Postgres instance. +Requirements: Rust (edition 2024), a running Postgres instance, and Node.js + npm (to build the embedded web client). + +> **Build the frontend first.** The binary embeds `frontend/dist/` at compile time via `rust-embed`, and that directory is gitignored — a fresh clone must produce it before the server will compile. Build it once with `make build` (runs `npm run build`, then `cargo build --release`), or manually: +> +> ```bash +> cd frontend && npm install && npm run build && cd .. +> ``` +> +> After `frontend/dist/` exists, the steps below work. `build.rs` marks it as a Cargo input, so a later `npm run build` triggers a rebuild of the binary that embeds it. To serve a client from disk instead of the embedded copy at runtime, set `FRONTEND_DIR` (the embed folder must still exist for compilation). ```bash cp .env.example .env diff --git a/assets/index.html b/assets/index.html deleted file mode 100644 index c72a168..0000000 --- a/assets/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/assets/script.js b/assets/script.js deleted file mode 100644 index ff5d515..0000000 --- a/assets/script.js +++ /dev/null @@ -1,26 +0,0 @@ -const username = "user"; -const password = "pass"; - -const socket = new WebSocket('ws://localhost:3000/ws'); - -socket.onopen = () => { - socket.send(JSON.stringify({ - Auth: { - username: "user", - password: "pass" - } - })); -} - -socket.onmessage = (event) => { - data = JSON.parse(event.data); - if (data == "AuthOk") { - console.log("Connected") - } else { - console.log(data) - } -} - -socket.onclose = () => { - console.log("Disconnected") -} \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9f39e37 --- /dev/null +++ b/build.rs @@ -0,0 +1,22 @@ +use std::{fs, path::Path}; + +fn main() { + watch_dir(Path::new("frontend/dist")); +} + +fn watch_dir(path: &Path) { + println!("cargo:rerun-if-changed={}", path.display()); + + let Ok(entries) = fs::read_dir(path) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + watch_dir(&path); + } else { + println!("cargo:rerun-if-changed={}", path.display()); + } + } +} diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example new file mode 100644 index 0000000..0c9c9c8 --- /dev/null +++ b/docker-compose.yaml.example @@ -0,0 +1,32 @@ +# Local development database. +# +# Requires PostgreSQL 18+ — the schema uses uuidv7(), a built-in added in PG18. +# Older images will fail migrations with "function uuidv7() does not exist". +# +# Usage: +# cp docker-compose.yaml.example docker-compose.yml +# docker compose up -d +# +# Credentials match the DATABASE_URL default in .env.example, so no extra +# config is needed for a stock local setup. + +services: + db: + image: postgres:18-alpine + restart: unless-stopped + environment: + POSTGRES_USER: relay + POSTGRES_PASSWORD: relay + POSTGRES_DB: relay + ports: + - "5432:5432" + volumes: + - relay_pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U relay -d relay"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + relay_pgdata: diff --git a/docs/architecture.md b/docs/architecture.md index 993ec60..748f285 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,9 +142,12 @@ cancellation token, and a `ServerControl` (the sending half of the control chann so an admin command can drive the lifecycle — §9). Axum clones it into each `/ws` upgrade. -Routes: `/` and `/script.js` (a minimal browser test client), `/health`, -`/favicon.ico`, and `/ws` (the WebSocket upgrade). A per-IP rate-limit layer -(`tower_governor`) wraps the router (§9). +Routes: `/health`, `/favicon.ico`, `/ws` (the WebSocket upgrade), and a **fallback +that serves the frontend** — the default Svelte client baked into the binary at +compile time with `rust-embed` (`Assets` in `src/handler.rs`), or a directory on +disk when `FRONTEND_DIR` is set (an operator override). Both fall back to +`index.html` for unmatched paths so the client's own routing works. A per-IP +rate-limit layer (`tower_governor`) wraps the router (§9). --- @@ -235,14 +238,23 @@ sender task over `sub_tx`: - on connect → subscribe to all current rooms; - on `JoinRoom` / `AcceptInvite` success → subscribe (self); -- on `LeaveRoom` success → unsubscribe. +- on `LeaveRoom` success → unsubscribe (self). -**Cross-session subscription.** When an owner *approves* someone else's join -request, the new member isn't the caller — so the room actor reaches that user's -sessions through the `sessions` presence map and pushes a subscribe to each -(`hub.subscribe_user_to_room`). Sessions register on connect and deregister via a -`SessionGuard` (RAII) when the task ends. Offline users simply have no entry and -subscribe on their next connect. +**Cross-session subscription changes.** Some membership changes are driven by +*another* user, so the affected user isn't the caller. The room actor reaches that +user's sessions through the `sessions` presence map and pushes the change to each: + +- **Approve a join request** → `hub.subscribe_user_to_room`: the admitted user's + open sessions start receiving the room's live events without reconnecting. +- **Remove a member (kick)** → `hub.unsubscribe_user_from_room`: the removed user's + sessions drop that room's live stream immediately (a `Subscription::Remove` to + each), rather than continuing to receive messages until their next reconnect. The + JIT membership checks already block their reads/writes; this closes the live feed + too. + +Sessions register on connect and deregister via a `SessionGuard` (RAII) when the +task ends. Offline users simply have no entry; they pick up the new state (as a +member, or no longer one) on their next connect. > **Consequence for clients:** the socket is a *mixed stream*. Command replies and > pushed events (`NewMessage`, `Resync`) interleave, and a sender even receives the @@ -266,15 +278,23 @@ bytes follow as **binary WebSocket frames**: (spawned on the first chunk, disposable). The actor persists chunks idempotently (`ON CONFLICT DO NOTHING` on `(attachment_id, seq)`), so a stalled upload *resumes* by re-sending only the missing seqs. When every chunk is present it streams them in -order through a SHA-256 hasher, checks size + digest, then CAS-flips `is_complete` -and emits `AttachmentComplete`. A bounded **write semaphore** caps concurrent chunk -writes so an upload burst can't drain the pool. Idle uploads time out; the reaper -sweeps abandoned partials. +order through a SHA-256 hasher and checks size + digest. It then **sniffs the file's +leading bytes against a content-type policy** (`resolve_content_type`): magic-detected +binary types (images/PDF/ZIP) are stored as their *detected* type — correcting a +mislabeled declaration, so the client can't lie — while magicless text types are +honored only from a small allowlist (and rejected if the bytes look binary). +Anything unsupported is rejected. On acceptance it CAS-flips `is_complete` (writing +the corrected `content_type` in the same statement) and emits `AttachmentComplete`; +on rejection it leaves the row incomplete and emits `AttachmentRejected { reason }` +to the uploader. A bounded **write semaphore** caps concurrent chunk writes so an +upload burst can't drain the pool. Idle uploads time out; the reaper sweeps +abandoned partials. (Catalog of the policy: [`reference/wire-protocol.md`](reference/wire-protocol.md).) **Download.** `DownloadAttachment` spawns a disposable streaming task: a -membership check, then chunks streamed back as binary frames (same framing) in seq -order, ended by `AttachmentEnd`. A bounded **read semaphore** mirrors the write -side. +**membership** check (the room's members only — note this is stricter than history +reads, which admins may also perform, §9), then chunks streamed back as binary +frames (same framing) in seq order, ended by `AttachmentEnd`. A bounded **read +semaphore** mirrors the write side. Full framing and event details: [`reference/wire-protocol.md`](reference/wire-protocol.md). @@ -366,6 +386,14 @@ re-checked at each privileged action, never cached on the session, so a revoked right takes effect on the next command. Client-facing errors are intentionally generic; detail belongs in server logs. +**Admin moderation reads.** Admins can read any room for moderation, even ones +they're not a member of: `GetMessages` resolves the room for a member *or* an admin +(`resolve_readable_room`), and `GetRoom` / `GetRoomMembership` already treat admins +as able to see private rooms. `ListAllRooms` (admin only) lists every room including +private, non-discoverable ones that `ListDiscoverableRooms` hides. Writes stay +member-gated: an admin reading a non-member room can't post, mark-read, or (today) +download its attachments — reading the message stream is the moderation surface. + --- ## 10. Where things live diff --git a/docs/client-contract.md b/docs/client-contract.md index 8962d15..2b06ce9 100644 --- a/docs/client-contract.md +++ b/docs/client-contract.md @@ -35,8 +35,13 @@ valid there: one-shot account-creation handshake: the server replies `UserCreated` then `Close`. To then *use* the account you must **open a new connection and `Auth`**. -Send nothing else until you've received `AuthOk`. Any other first frame closes the -socket. +The one exception: you may send **`GetSignupStatus`** in the prelude (before +authenticating) and the server replies `SignupStatus { open_signups }` **without** +ending the prelude — so a login screen can decide whether to offer registration +before the user does anything. You can then `Auth` / `NewUser` on the same socket. +Apart from that query, send nothing else until you've received `AuthOk`: any other +first frame closes the socket. (The query is rate-capped pre-auth, so query once and +cache the answer rather than polling.) ## 2. The socket is a mixed stream @@ -44,9 +49,11 @@ After `AuthOk` there is **no strict request/response framing**. Command replies unsolicited **pushed events** share the one socket and interleave in unspecified order. Your read loop must dispatch by event type, not by position. In particular: -- `NewMessage`, `Resync` (live room events) can arrive at any moment — including - *between* a command you sent and its reply, and *during* an attachment download's - binary frames. +- `NewMessage`, `MessageRemoved`, `Resync` (live room events) can arrive at any + moment — including *between* a command you sent and its reply, and *during* an + attachment download's binary frames. `MessageRemoved { room_name, message_id }` + means drop that message from the view; it fires on an unsend, an admin removal, or + a file-only upload whose attachment was rejected. - A command's reply is identified by its **type/content**, not by being "next." A client that does blocking "send command, read one frame as the reply" will @@ -79,7 +86,14 @@ Bytes never travel as JSON. The flow: ``` [ attachment_id : 16B ][ seq : u32 big-endian : 4B ][ payload … ] ``` -4. On success you get `AttachmentComplete { attachment_id }`. +4. The server verifies the bytes and applies its content-type policy, then replies + with **either** `AttachmentComplete { attachment_id }` (accepted) **or** + `AttachmentRejected { attachment_id, reason }` (the file isn't an allowed type, or + its bytes don't match a text declaration). Key your upload UI off the + `attachment_id` and handle both outcomes. On rejection the server also **deletes + the attachment** and broadcasts `AttachmentRejected` to room subscribers so live + clients remove that attachment from view. If that empties the message, the server + also **deletes the message** and broadcasts `MessageRemoved`; clients drop it. Obligations: @@ -90,6 +104,13 @@ Obligations: declared `size_bytes` and `content_sha256`. A mismatch leaves the attachment permanently incomplete and yields a generic failure — re-sending can't fix a bad declaration (chunks are keep-first). Recompute and re-declare on a new message. +- **Only supported types are accepted, and the server is authoritative on type.** + After the bytes verify, the server sniffs them: it stores the *detected* type for + recognized binary formats (correcting a mislabeled `content_type`) and honors only + an allowlist of text types otherwise; unsupported or mismatched files get + `AttachmentRejected` and never complete. Don't rely on your declared `content_type` + surviving unchanged — read it back from history. See the policy in + [`reference/wire-protocol.md`](reference/wire-protocol.md#attachment-content-type-policy). - **Keep chunks flowing.** An upload that idles is abandoned server-side after a short timeout. Resumption is supported — re-sending a seq is idempotent, so a stalled upload continues by sending only the missing seqs — but you must drive it. diff --git a/docs/reference/actors-and-channels.md b/docs/reference/actors-and-channels.md index 344974b..9413016 100644 --- a/docs/reference/actors-and-channels.md +++ b/docs/reference/actors-and-channels.md @@ -61,13 +61,14 @@ Handle::op(args) |---|---|---| | auth | `AuthHandle` | `authenticate(username, password) → user_id` | | user | `UserHandle` | `new_user`, `edit_user`, `promote`, `demote`, `delete_user`, `update_password`, `reset_password`, `is_admin`, `get_user_by_username` | -| room | `RoomHandle` | `new_room`, `get_room`, `get_room_members`, `set_room_name`, `add_room_owner`, `join_room`, `leave_room`, invites (`invite`/`accept`/`decline`/`get_my_invites`), requests (`approve`/`reject`/`get_my`/`get_incoming`) | +| room | `RoomHandle` | `new_room`, `get_room`, `get_room_members`, `set_room_name`, `add_room_owner`, `join_room`, `leave_room`, `remove_room_member` (kick), `list_discoverable_rooms`, `list_all_rooms` (admin), invites (`invite`/`accept`/`decline`/`get_my_invites`), requests (`approve`/`reject`/`cancel`/`get_my`/`get_incoming`) | | message | `MessageHandle` | `send_message`, `get_messages`, `mark_read`, `get_unread_summary`, `add_reaction`, `remove_reaction` | > `auth` is spawned in `main` (before `app()`) so the WebSocket prelude can > authenticate without the rest of `AppState`. `room` and `message` receive a `Hub` > clone at spawn — `message` to **publish** `NewMessage`, `room` to **subscribe** a -> just-approved user's sessions cross-session. +> just-approved user's sessions and to **unsubscribe** a just-removed (kicked) user's +> sessions, both cross-session. ## Per-connection channels (`handle_socket`) @@ -87,12 +88,13 @@ teardown). | Map | Key → Value | Written by | Read by | |---|---|---|---| | `rooms` | `room_id → broadcast::Sender>` | `subscribe` (lazy create), `publish` (self-prune) | sender tasks (via `subscribe`) | -| `sessions` | `user_id → [ { session_id, sub_tx } ]` | `register_session` / `SessionGuard` drop | `subscribe_user_to_room` | +| `sessions` | `user_id → [ { session_id, sub_tx } ]` | `register_session` / `SessionGuard` drop | `subscribe_user_to_room`, `unsubscribe_user_from_room` | Methods: `subscribe(room_id) → Receiver`, `publish(room_id, event)`, `register_session(user_id, sub_tx) → SessionGuard`, -`subscribe_user_to_room(user_id, room_id, room_name)`. Each is sync and -non-blocking; the lock is never held across `.await`. +`subscribe_user_to_room(user_id, room_id, room_name)`, +`unsubscribe_user_from_room(user_id, room_id)`. Each is sync and non-blocking; the +lock is never held across `.await`. ## Concurrency bounds (where the limits are) diff --git a/docs/reference/data-model.md b/docs/reference/data-model.md index 2e41a17..02250de 100644 --- a/docs/reference/data-model.md +++ b/docs/reference/data-model.md @@ -67,10 +67,14 @@ whitespace-trimmed: lookups use `LOWER(trim_ws($1))` against the unique `LOWER(...)` indexes (`trim_ws` is a custom immutable SQL function). **Attachment completeness.** `message_attachments.is_complete` flips true only after -every chunk is present and the streamed SHA-256 + total size match the declaration -(a monotonic `false → true` CAS). Incomplete rows are swept by the reaper after a -~24h grace; the partial-index `message_attachments_incomplete (created_at) WHERE NOT -is_complete` serves that sweep. +every chunk is present, the streamed SHA-256 + total size match the declaration, and +the bytes pass a **content-type policy** (a magic-byte sniff: the stored +`content_type` is set to the *detected* type for recognized formats, or the file is +rejected) — a monotonic `false → true` CAS that also writes the corrected +`content_type`. So `content_type` is **server-verified**, not merely client-declared. +Rejected and incomplete rows are swept by the reaper after a ~24h grace; the +partial-index `message_attachments_incomplete (created_at) WHERE NOT is_complete` +serves that sweep. ## Bootstrap & cleanup diff --git a/docs/reference/wire-protocol.md b/docs/reference/wire-protocol.md index d93141f..114162c 100644 --- a/docs/reference/wire-protocol.md +++ b/docs/reference/wire-protocol.md @@ -36,7 +36,9 @@ not answered with an error. Query the limit with `GetMaxChunkSize`. ## Connection lifecycle ``` -client ──► Auth (or NewUser if open signups) first frame only (the "prelude") +client ──► GetSignupStatus (optional, repeatable) prelude — non-terminal +server ──► SignupStatus { open_signups } ...prelude keeps waiting +client ──► Auth (or NewUser if open signups) the terminal prelude frame server ──► AuthOk → enter command loop ──► NoAuth + Close → rejected ──► UserCreated+Close → signup-only handshake done @@ -44,6 +46,10 @@ server ──► AuthOk → enter command loop either ──► Close → teardown ``` +In the prelude only `GetSignupStatus` is **non-terminal** (answered, then the server +keeps waiting); it's capped so an unauthenticated socket can't query indefinitely. +`Auth` / `NewUser` end the prelude, and any other frame closes the connection. + After `AuthOk` the socket is a **mixed stream**: command replies and **pushed events** (`NewMessage`, `Resync`) interleave, and a sender receives the live echo of its own message. Clients must **dedup by `message_id`** and tolerate pushes at any @@ -56,27 +62,33 @@ time. |---|---|---| | `Auth` | `username`, `password` | `AuthOk` / `NoAuth`+`Close` (prelude only) | | `Echo` | `string` | `Echo` | -| `SendMessage` | `room_id`, `content`, `attachments[]` | `MessageCreated` | -| `GetMessages` | `room_name`, `before?`, `limit?` | `MessageHistory` | +| `SendMessage` | `room_name`, `content`, `attachments[]` | `MessageCreated` | +| `GetMessages` | `room_name`, `before?`, `limit?` | `MessageHistory` (members **or admins**) | | `MarkRead` | `room_name`, `up_to_message_id` | `Success` | | `GetUnreadSummary` | — | `UnreadSummary` | | `AddReaction` | `message_id`, `emoji` | `Success` | | `RemoveReaction` | `message_id`, `emoji` | `Success` | +| `DeleteMessage` | `message_id` | `Success` (sender **or admin**); also fans out `MessageRemoved` | | `DownloadAttachment` | `attachment_id` | binary chunk frames → `AttachmentEnd` | | `GetMaxChunkSize` | — | `MaxChunkSize` | +| `GetSignupStatus` | — | `SignupStatus` (valid **in the prelude**, pre-auth, and after) | | `Close` | — | `Close` | | `Error` | `error` | (client→server error; ignored) | `attachments[]` items are `NewMessageAttachment { filename, content_type, size_bytes, chunk_count, content_sha256 }`. After `MessageCreated`, stream each file's bytes as binary chunk frames keyed by the returned `attachment_ids` (in -declaration order). +declaration order). On completion the server **sniffs the uploaded bytes** and +enforces a content-type policy (see §"Attachment content-type policy" below): the +declared `content_type` is advisory, and a disallowed file is rejected with +`AttachmentRejected` instead of `AttachmentComplete`. ### Users / admin | Command | Fields | |---|---| | `NewUser` | `username`, `password`, `first_name?`, `last_name?`, `alias?` | | `GetUserByUsername` | `username` → `UserInfo` | +| `GetUsers` | `starts_with?`, `after?`, `limit?` → `Users` (any authenticated user) | | `EditUser` | `target_username`, `username?`, `first_name?`, `last_name?`, `alias?` | | `Promote` / `Demote` / `DeleteUser` | `target_username` | | `UpdatePassword` | `current_password`, `new_password` | @@ -92,12 +104,20 @@ declaration order). | `AddRoomOwner` | `room_name`, `new_owner_username` | | `JoinRoom` | `room_name` → `Success` (public) or `JoinRequested` (private+discoverable) | | `LeaveRoom` | `room_name` | +| `RemoveRoomMember` | `room_name`, `member_username` — owner/admin removes another member (kick) | | `InviteToRoom` | `room_name`, `invitee_username` | | `GetMyInvites` | — → `MyInvites` | | `AcceptInvite` / `DeclineInvite` | `room_name` | | `GetMyJoinRequests` | — → `MyJoinRequests` | +| `CancelJoinRequest` | `room_name` — withdraw your own pending request | | `GetIncomingJoinRequests` | — → `IncomingJoinRequests` | | `ApproveJoinRequest` / `RejectJoinRequest` | `room_name`, `requester_username` | +| `ListDiscoverableRooms` | — → `DiscoverableRooms` (public or discoverable rooms) | +| `ListAllRooms` | — → `AllRooms` (**admin only**; every room, private included) | + +Owner/admin authority on a room is re-checked just-in-time. `RemoveRoomMember` +also drops the kicked user's live subscription to that room (via the Hub), so they +stop receiving its messages immediately, not at next reconnect. ### Server lifecycle (admin only) | Command | Fields | Effect | @@ -114,25 +134,38 @@ A restart does **not** re-read the config file or move the bind port — see ### Replies & data | Event | Fields | |---|---| -| `AuthOk` / `NoAuth` | — | +| `AuthOk` | `is_admin` (whether the logged-in user is an admin) | +| `NoAuth` | — | | `UserCreated` | — | | `Echo` | `string` | | `MessageCreated` | `message_id`, `attachment_ids[]`, `message` (`MessageHistoryItem`) | | `MessageHistory` | `room_name`, `messages[]` (`MessageHistoryItem`, newest-first) | | `UnreadSummary` | `rooms[]` (`RoomUnread { room_name, unread }`) | | `MaxChunkSize` | `bytes` | +| `SignupStatus` | `open_signups` (whether unauthenticated account creation is enabled) | | `UserInfo` | `first_name?`, `last_name?`, `alias?`, `username`, `created_at` | | `RoomInfo` | `room_name`, `is_public`, `is_discoverable` | -| `RoomMembers` | `members[]` (`PublicUser`, no `user_id`) | +| `RoomMembers` | `members[]` (`RoomMember` = `PublicUser` + `is_owner`, no `user_id`) | | `MyInvites` / `MyJoinRequests` | `rooms[]` (names) | | `IncomingJoinRequests` | `requests[]` (`JoinRequestInfo`) | +| `DiscoverableRooms` / `AllRooms` | `rooms[]` (`DiscoverableRoom { room_name, is_public, member_count }`) | +| `Users` | `users[]` (`UserDirectoryEntry`), `has_more` (another page follows) | + +`UserDirectoryEntry` is `PublicUser` plus an optional `is_admin`, which is present +**only when the requester is an admin** (and omitted otherwise, so admin status +isn't exposed to non-admins). `GetUsers` pages the directory ordered by username: +pass the last entry's `username` as the next `after` cursor, and an optional +`starts_with` to filter by a case-insensitive username prefix. `limit` is clamped +server-side (default 50, max 100). Open to any authenticated user. ### Live push (after auth, unsolicited) | Event | Fields | Meaning | |---|---|---| | `NewMessage` | `room_name`, `message` | a message was posted to a subscribed room | +| `MessageRemoved` | `room_name`, `message_id` | a message was deleted (unsend, admin removal, or a rejected file-only upload) — drop it from the view | | `Resync` | `room_name` | fell behind the live buffer — re-fetch this room via `GetMessages` | -| `AttachmentComplete` | `attachment_id` | an upload finished and verified | +| `AttachmentComplete` | `attachment_id` | an upload finished, verified, and passed the content-type policy | +| `AttachmentRejected` | `attachment_id`, `reason` | an upload's bytes verified but failed the content-type policy; the file is not published | | `AttachmentChunk` | binary frame | one chunk of an in-progress download | | `AttachmentEnd` | `attachment_id` | download stream finished cleanly | @@ -151,5 +184,30 @@ attachments[]: { attachment_id, filename, content_type, size_bytes, is_complete reactions[]: { emoji, count, reacted_by_me } ``` +### Attachment content-type policy + +Once an upload's bytes pass the size + SHA-256 check, the server sniffs the leading +bytes (chunk 0) and decides the **stored** `content_type` — the client's declared +value is only advisory: + +- **Magic-detected types** (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, + `application/pdf`, `application/zip`): detection wins. The stored `content_type` is + set to the detected type, **correcting** a mislabeled-but-supported file. A + detected type outside this set is **rejected**. +- **Magicless text types** (`text/plain`, `text/csv`, `text/markdown`, + `application/json`, `image/svg+xml`): no signature exists, so the declared type is + honored — but only if it's in this allowlist and the bytes contain no NUL + (otherwise it's a binary masquerading as text, and it's rejected). +- Anything else (e.g. `application/octet-stream`, unknown types) is **rejected**. + +A rejection emits `AttachmentRejected { attachment_id, reason }` instead of +`AttachmentComplete`, and the upload is **cancelled**: the attachment row and its +chunks are deleted outright (not left for the reaper), and `AttachmentRejected` is +fanned out to the room so live clients remove that dead attachment. If that empties +the parent message of all attachments — the usual case, since a file post's caption +defaults to the filename — the message is deleted too and a `MessageRemoved` is +fanned out to the room, so it doesn't linger as a bare filename for other members. +Source: `src/attachment.rs` (`resolve_content_type`, `cancel_rejected_upload`). + > Legacy: `ServerEvent::Message { user_id, room_id, value }` still exists in > `model.rs` but is never emitted — superseded by `NewMessage`. Slated for removal. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..bdef820 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..927495d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,30 @@ + + + + + + + relay + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6fd712d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2345 @@ +{ + "name": "relay-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "relay-frontend", + "version": "0.0.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tsconfig/svelte": "^5.0.4", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "svelte": "^5.1.3", + "svelte-check": "^4.0.5", + "tailwindcss": "^3.4.19", + "tslib": "^2.8.0", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.367", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz", + "integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.2.tgz", + "integrity": "sha512-1lDf8TLqpxyAt3xgybfytWPJQbaUD6TiDgpiCLH0BKrKEwzecB9pjuNVnEJMpzH018xUzo6oxheK2HT0oa2RoQ==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d57dfdd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "relay-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tsconfig/svelte": "^5.0.4", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "svelte": "^5.1.3", + "svelte-check": "^4.0.5", + "tailwindcss": "^3.4.19", + "tslib": "^2.8.0", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..48cbdb2 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,15 @@ + + +{#if $authed} + +{:else} + +{/if} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..5a67fc6 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,86 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + margin: 0; + padding: 0; +} + +/* Background set immediately by the anti-flash inline script class */ +html { + background-color: #dce4ec; + color-scheme: light; +} +html.dark { + background-color: #0c1117; + color-scheme: dark; +} + +body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 1rem; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +::selection { + background: rgba(14, 165, 233, 0.22); +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.28); + border: 3px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.42); + border: 3px solid transparent; + background-clip: padding-box; +} + +/* Indeterminate "still chunking / awaiting server confirmation" sweep. The send + loop queues chunks near-instantly, so real time is spent waiting for the + server to receive, verify, and ack (AttachmentComplete); an indeterminate + sweep is the honest representation of that wait. */ +@keyframes relay-sweep { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(360%); } +} +.relay-sweep { + animation: relay-sweep 1.05s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .relay-sweep { animation: none; transform: none; width: 100% !important; opacity: 0.5; } +} diff --git a/frontend/src/lib/Login.svelte b/frontend/src/lib/Login.svelte new file mode 100644 index 0000000..80b334e --- /dev/null +++ b/frontend/src/lib/Login.svelte @@ -0,0 +1,305 @@ + + +
+
+
+ + setFontSize(Number(e.currentTarget.value))} + title="Font size" + aria-label="Font size" + /> + {$fontSize} +
+ + +
+ +
+
+

relay

+

+ {mode === 'login' ? 'Sign in to your rooms.' : 'Create an account to start messaging.'} +

+
+ + {#if notice} +

{notice}

+ {/if} + +
+
+ + +
+ +
+ + +
+ + {#if mode === 'register'} +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ {/if} + + {#if error} +

{error}

+ {/if} + +
+ {#if mode === 'login'} + {#if $signupsOpen} + + {:else} + + + {/if} + {:else} + + {/if} + + +
+
+ + {#if $connectionState === 'connecting'} +

connecting...

+ {/if} +
+
diff --git a/frontend/src/lib/canvas/AdminPanel.svelte b/frontend/src/lib/canvas/AdminPanel.svelte new file mode 100644 index 0000000..163cf63 --- /dev/null +++ b/frontend/src/lib/canvas/AdminPanel.svelte @@ -0,0 +1,344 @@ + + +
+ + +
+

directory

+ + {#if dirErr} +

{dirErr}

+ {:else if dirUsers.length === 0 && !dirLoading} +

no users

+ {:else} +
+ {#each dirUsers as u (u.username)} + + {/each} +
+ {#if dirHasMore} + + {/if} + {/if} +
+ + +
+

user management

+
+ + +
+ + {#if lookupState === 'pending'} +

searching

+ {:else if lookupState === 'notfound'} +

no such user

+ {:else if lookupState === 'found'} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + +
+ + +
+
+ + +
+ +
+ + +
+ {#if confirmDelete} + delete {username}? + + + {:else} + + {/if} +
+ + {#if actionMsg}

{actionMsg}

{/if} + {#if actionErr}

{actionErr}

{/if} +
+ {/if} +
+ + +
+

server control

+
+
+ {#if confirmRestart} + restart the server? + + + {:else} + + {/if} +
+
+ {#if confirmShutdown} + shut the server down? + + + {:else} + + {/if} +
+ {#if serverMsg}

{serverMsg}

{/if} +
+
+ +
diff --git a/frontend/src/lib/canvas/AttachmentImage.svelte b/frontend/src/lib/canvas/AttachmentImage.svelte new file mode 100644 index 0000000..302c0d1 --- /dev/null +++ b/frontend/src/lib/canvas/AttachmentImage.svelte @@ -0,0 +1,109 @@ + + +{#if upload === 'error'} +
+ {att.filename} + rejected +
+{:else if status === 'ready' && url} +
+ + + {#if upload === 'uploading'} + +
+
+
+
+ uploading +
+ {:else if upload === 'complete'} + +
+ sent ✓ +
+ {/if} +
+{:else if status === 'loading'} +
+ loading image +
+{:else} +
+ image unavailable +
+{/if} diff --git a/frontend/src/lib/canvas/Canvas.svelte b/frontend/src/lib/canvas/Canvas.svelte new file mode 100644 index 0000000..768a12a --- /dev/null +++ b/frontend/src/lib/canvas/Canvas.svelte @@ -0,0 +1,489 @@ + + + { if (e.key === 'Escape') closeViewer(); }} +/> + + +
+
+ + + + + + +{#if !isMobile} + {#each $panes as pane (pane.id)} + + {#if pane.type === 'directory'} + + {:else if pane.type === 'profile'} + + {:else if pane.type === 'admin'} + + {:else if pane.type === 'roominfo'} + + {:else} + + {/if} + + {/each} +{/if} + +{#if isMobile} +
+
+ + +
+

+ {activePane?.title ?? 'relay'} +

+

+ {activePane ? (activePane.type === 'room' ? 'chat' : activePane.type) : 'open the directory to start'} +

+
+ + + + {#if activePane} + + {/if} +
+ +
+ {#if activePane} + {#key activePane.id} +
+ {#if activePane.type === 'directory'} + + {:else if activePane.type === 'profile'} + + {:else if activePane.type === 'admin'} + + {:else if activePane.type === 'roominfo'} + + {:else} + + {/if} +
+ {/key} + {:else} +
+

No view open

+ +
+ {/if} +
+ +
+ {#if roomPanes.length > 1 && activeRoomIndex >= 0} + + {/if} + + {#if roomPanes.length > 0} +
+ {#each roomPanes as room} + + {/each} +
+ {:else} +

Open rooms from the directory.

+ {/if} +
+
+{/if} + +{#if $imageViewer} + + +
+
+

{$imageViewer.filename}

+
+
+ + + +
+ +
+
+ +
1 && !draggingImage} + class:cursor-grabbing={draggingImage} + on:click|stopPropagation + on:pointerdown|stopPropagation={startImageDrag} + on:pointermove|stopPropagation={onImageDrag} + on:pointerup|stopPropagation={stopImageDrag} + on:pointercancel|stopPropagation={stopImageDrag} + on:dblclick|stopPropagation={toggleQuickZoom} + on:wheel|preventDefault={onImageWheel} + > + {$imageViewer.filename} +
+

+ Scroll to zoom. Drag while zoomed to inspect details. +

+
+{/if} diff --git a/frontend/src/lib/canvas/ColorMenu.svelte b/frontend/src/lib/canvas/ColorMenu.svelte new file mode 100644 index 0000000..d00a39f --- /dev/null +++ b/frontend/src/lib/canvas/ColorMenu.svelte @@ -0,0 +1,40 @@ + + + + +
+

{username}

+
+ {#each PALETTE as color} + + {/each} +
+ +
diff --git a/frontend/src/lib/canvas/Directory.svelte b/frontend/src/lib/canvas/Directory.svelte new file mode 100644 index 0000000..3bfd83b --- /dev/null +++ b/frontend/src/lib/canvas/Directory.svelte @@ -0,0 +1,510 @@ + + +
+ + +
+ +
+ + +
+ + + {#if searchState !== 'idle'} +
+

result

+ {#if searchState === 'pending'} +

searching

+ {:else if searchState === 'notfound'} +

no room found

+ {:else if searchResult} + {@const inJoined = joinedNames.has(searchResult.room_name)} + + {/if} +
+ {/if} + + + {#if joinedRooms.length > 0} +
+

my rooms

+ {#each joinedRooms as room (room.room_name)} + + {/each} +
+ {/if} + + + {#if myInvites.length > 0} +
+ + {#if showInvites} + {#each myInvites as room} +
+ #{room} + + +
+ {/each} + {/if} +
+ {/if} + + + {#if myRequests.length > 0} +
+ + {#if showRequests} + {#each myRequests as room} +
+ #{room} + pending + +
+ {/each} + {/if} +
+ {/if} + + + {#if incomingRequests.length > 0} +
+ + {#if showIncoming} + {#each incomingRequests as req} +
+ + {req.username} + + #{req.room_name} + + + +
+ {/each} + {/if} +
+ {/if} + + + {#if searchState === 'idle' && discoverableRooms.length > 0} + {@const unjoined = discoverableRooms.filter(r => !joinedNames.has(r.room_name))} + {#if unjoined.length > 0} +
+

discover

+ {#each unjoined as room (room.room_name)} + + {/each} +
+ {/if} + {:else if searchState === 'idle' && discoverableRooms.length === 0} +

+ search by name to find rooms +

+ {/if} + + + {#if $isAdmin && searchState === 'idle' && allRooms.length > 0} +
+ + {#if showAllRooms} + {#each allRooms as room (room.room_name)} + + {/each} + {/if} +
+ {/if} + +
+ + +
+ + + {#if showCreate} +
+ +
+ + +
+ {#if createError} +

{createError}

+ {/if} +
+ + +
+
+ {:else} + + {/if} + + +
+ + {#if $isAdmin} + + {/if} +
+ +
+
diff --git a/frontend/src/lib/canvas/Pane.svelte b/frontend/src/lib/canvas/Pane.svelte new file mode 100644 index 0000000..2c53f16 --- /dev/null +++ b/frontend/src/lib/canvas/Pane.svelte @@ -0,0 +1,114 @@ + + + +
focusPane(pane.id)} +> + + +
+ {pane.title} + + +
+ + +
+ +
+ + + +
+
+
+
diff --git a/frontend/src/lib/canvas/ProfilePanel.svelte b/frontend/src/lib/canvas/ProfilePanel.svelte new file mode 100644 index 0000000..2330740 --- /dev/null +++ b/frontend/src/lib/canvas/ProfilePanel.svelte @@ -0,0 +1,196 @@ + + +
+ + +
+

profile

+ {#if !loaded} +

loading

+ {:else} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {#if profileMsg}{profileMsg}{/if} + {#if profileErr}{profileErr}{/if} +
+
+ {/if} +
+ + +
+

change password

+
+
+ + +
+
+ + +
+
+ + +
+
+ + {#if pwMsg}{pwMsg}{/if} + {#if pwErr}{pwErr}{/if} +
+
+
+ +
diff --git a/frontend/src/lib/canvas/Room.svelte b/frontend/src/lib/canvas/Room.svelte new file mode 100644 index 0000000..00e43d3 --- /dev/null +++ b/frontend/src/lib/canvas/Room.svelte @@ -0,0 +1,837 @@ + + + + +{#if colorMenu} + +{/if} + +
+ + +
+ #{roomName} + +
+ + + +
+ + {#if hasMore} +
+ {#if loadingMore} + loading + {:else} + + {/if} +
+ {/if} + + {#if messages.length === 0 && initialLoaded} +

no messages yet

+ {/if} + + {#each messages as msg, i} + {@const showHeader = i === 0 || messages[i - 1].sender_username !== msg.sender_username} + {@const color = $userColors[msg.sender_username]} + +
+ + {#if showHeader} + +
+ onUsernameRightClick(e, msg.sender_username)} + >{msg.sender_username} + {formatTime(msg.timestamp)} +
+ {/if} + +

{msg.content}

+ + {#if msg.attachments.length > 0} +
+ {#each msg.attachments as att (att.attachment_id)} + {@const upload = $uploadProgress[att.attachment_id]} + {#if att.content_type.startsWith('image/')} + openImageViewer(e.detail.url, e.detail.filename)} + on:loaded={onAttachmentLoaded} + /> + {:else if upload?.status === 'error'} +
+ 📎 + {att.filename} + rejected +
+ {:else} + + {/if} + {/each} +
+ {/if} + + +
+ {#each msg.reactions as r (r.emoji)} + + {/each} + + {#if emojiTarget === msg.message_id} + + +
+
+ {#each REACTION_PRESETS as emoji} + + {/each} +
+
addCustomReaction(msg.message_id)}> + + +
+
+ {:else} + + {/if} + + {#if canDelete(msg)} + {@const own = ownMessage(msg)} + + {/if} +
+ +
+ {/each} +
+ + + {#if readOnly} +
+ read-only · moderation view +
+ {:else} +
+
+ + +
+
+ {#if uploadError} +

{uploadError}

+ {/if} +
+ {/if} + +
diff --git a/frontend/src/lib/canvas/RoomInfoPanel.svelte b/frontend/src/lib/canvas/RoomInfoPanel.svelte new file mode 100644 index 0000000..6cfe808 --- /dev/null +++ b/frontend/src/lib/canvas/RoomInfoPanel.svelte @@ -0,0 +1,329 @@ + + +
+ + +
+

#{roomName}

+

+ {isPublic ? 'public' : 'private'}{isDiscoverable ? ' · discoverable' : ''} +

+
+ + +
+

members

+ {#if !loaded} +

loading

+ {:else if members.length === 0} +

no members

+ {:else} +
+ {#each members as m (m.username)} +
+ + {displayName(m)} + {#if m.alias?.trim()} + @{m.username} + {/if} + + {#if m.is_owner} + + owner + + {/if} + {#if canManage && m.username !== $currentUsername} + {#if confirmRemove === m.username} + + + {:else} + + {/if} + {/if} +
+ {/each} +
+ {/if} +
+ + + {#if canManage} +
+

manage

+ +
+
+ + + {#if showSuggestions && inviteName.trim() && (inviteSuggestions.length > 0 || inviteSearching)} +
+ {#if inviteSuggestions.length === 0 && inviteSearching} +

searching

+ {:else if inviteSuggestions.length === 0} +

no matches

+ {:else} + {#each inviteSuggestions as u (u.username)} + + {/each} + {/if} +
+ {/if} +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ {/if} + + +
+
+ {#if confirmLeave} + leave #{roomName}? + + + {:else} + + {/if} +
+
+ + {#if msg}

{msg}

{/if} + {#if err}

{err}

{/if} + +
diff --git a/frontend/src/lib/canvas/store.ts b/frontend/src/lib/canvas/store.ts new file mode 100644 index 0000000..c4d5710 --- /dev/null +++ b/frontend/src/lib/canvas/store.ts @@ -0,0 +1,132 @@ +import { writable } from 'svelte/store'; +import type { PaneState, PaneType } from './types'; + +const MIN_WIDTH = 300; +const MIN_HEIGHT = 200; +const DEFAULT_WIDTH = 500; +const DEFAULT_HEIGHT = 440; +const CASCADE_STEP = 26; +const CASCADE_LIMIT = 12; + +const { subscribe, update } = writable([]); + +let zTop = 100; +let cascadeCount = 0; + +function bumpZ(ps: PaneState[], id: string): PaneState[] { + const z = ++zTop; + return ps.map((p) => (p.id === id ? { ...p, z } : p)); +} + +export const panes = { subscribe }; + +export const imageViewer = writable<{ url: string; filename: string } | null>(null); + +export function openImageViewer(url: string, filename = 'attachment') { + imageViewer.set({ url, filename }); +} + +export function closeImageViewer() { + imageViewer.set(null); +} + +export function openPane( + id: string, + title: string, + type: PaneType, + dims?: { width?: number; height?: number; readOnly?: boolean; unread?: number }, +) { + update((ps) => { + if (ps.find((p) => p.id === id)) { + return bumpZ(ps, id).map((p) => + p.id === id + ? { ...p, readOnly: dims?.readOnly ?? p.readOnly, unread: dims?.unread ?? p.unread } + : p, + ); + } + const offset = (cascadeCount % CASCADE_LIMIT) * CASCADE_STEP; + cascadeCount++; + return [ + ...ps, + { + id, + title, + type, + x: 64 + offset, + y: 48 + offset, + width: dims?.width ?? DEFAULT_WIDTH, + height: dims?.height ?? DEFAULT_HEIGHT, + z: ++zTop, + readOnly: dims?.readOnly ?? false, + unread: dims?.unread ?? 0, + }, + ]; + }); +} + +export function closePane(id: string) { + update((ps) => ps.filter((p) => p.id !== id)); +} + +// Open the pane if absent; if it's already open, close it (toggle). Used by +// single-button launchers like the hamburger so a second click dismisses. +export function togglePane( + id: string, + title: string, + type: PaneType, + dims?: { width?: number; height?: number; readOnly?: boolean; unread?: number }, +) { + let exists = false; + update((ps) => { + exists = ps.some((p) => p.id === id); + return ps; + }); + if (exists) closePane(id); + else openPane(id, title, type, dims); +} + +// Retarget a room's open panes after a server-side rename. The room pane (id == +// room name) and its info pane (id == `roominfo:`) are keyed off the name, +// so they go stale once the room is renamed. Rewriting id + title here makes the +// keyed {#each} remount them under the new name, which also keeps reopen-dedup +// correct (openPane(newName) now finds the existing pane). Other panes that +// reference newName are left as-is to avoid id collisions; a name is unique +// server-side so a successful rename means no other room owns it. +export function renameRoomPanes(oldName: string, newName: string) { + update((ps) => { + const oldRoomInfoId = `roominfo:${oldName}`; + const newRoomInfoId = `roominfo:${newName}`; + // Guard against colliding with an already-open pane under the new ids. + const collides = ps.some( + (p) => p.id === newName || p.id === newRoomInfoId, + ); + if (collides) return ps; + return ps.map((p) => { + if (p.type === 'room' && p.id === oldName) { + return { ...p, id: newName, title: `#${newName}` }; + } + if (p.type === 'roominfo' && p.id === oldRoomInfoId) { + return { ...p, id: newRoomInfoId, title: `#${newName} · info` }; + } + return p; + }); + }); +} + +export function focusPane(id: string) { + update((ps) => bumpZ(ps, id)); +} + +export function movePane(id: string, x: number, y: number) { + update((ps) => ps.map((p) => (p.id === id ? { ...p, x, y } : p))); +} + +export function resizePane(id: string, w: number, h: number) { + update((ps) => + ps.map((p) => + p.id === id + ? { ...p, width: Math.max(MIN_WIDTH, w), height: Math.max(MIN_HEIGHT, h) } + : p, + ), + ); +} diff --git a/frontend/src/lib/canvas/types.ts b/frontend/src/lib/canvas/types.ts new file mode 100644 index 0000000..83c1c70 --- /dev/null +++ b/frontend/src/lib/canvas/types.ts @@ -0,0 +1,14 @@ +export type PaneType = 'room' | 'directory' | 'profile' | 'admin' | 'roominfo'; + +export interface PaneState { + id: string; // room_name, '__directory__', '__profile__', '__admin__', or 'roominfo:' + title: string; // display label in title bar + type: PaneType; + x: number; + y: number; + width: number; + height: number; + z: number; // stacking order; higher = on top + readOnly?: boolean; // room panes opened by an admin for moderation (no composer) + unread?: number; +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..4c53b46 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -0,0 +1,65 @@ +import { writable } from 'svelte/store'; + +const THEME_KEY = 'relay_theme'; +const FONT_SIZE_KEY = 'relay_font_size'; +export const MIN_FONT_SIZE = 13; +export const MAX_FONT_SIZE = 18; +export const DEFAULT_FONT_SIZE = 15; + +function loadPrefers(): boolean { + try { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'dark') return true; + if (stored === 'light') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } catch { + return true; + } +} + +function clampFontSize(size: number): number { + return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(size))); +} + +function loadFontSize(): number { + try { + const stored = Number(localStorage.getItem(FONT_SIZE_KEY)); + return Number.isFinite(stored) ? clampFontSize(stored) : DEFAULT_FONT_SIZE; + } catch { + return DEFAULT_FONT_SIZE; + } +} + +function applyFontSize(size: number): void { + if (typeof document === 'undefined') return; + document.documentElement.style.fontSize = `${clampFontSize(size)}px`; +} + +export const isDark = writable( + typeof window !== 'undefined' ? loadPrefers() : true, +); + +export const fontSize = writable( + typeof window !== 'undefined' ? loadFontSize() : DEFAULT_FONT_SIZE, +); + +export function toggleTheme(): void { + isDark.update(d => { + const next = !d; + if (next) document.documentElement.classList.add('dark'); + else document.documentElement.classList.remove('dark'); + try { localStorage.setItem(THEME_KEY, next ? 'dark' : 'light'); } catch {} + return next; + }); +} + +export function setFontSize(size: number): void { + const next = clampFontSize(size); + applyFontSize(next); + try { localStorage.setItem(FONT_SIZE_KEY, String(next)); } catch {} + fontSize.set(next); +} + +if (typeof window !== 'undefined') { + applyFontSize(loadFontSize()); +} diff --git a/frontend/src/lib/userColors.ts b/frontend/src/lib/userColors.ts new file mode 100644 index 0000000..a01b37e --- /dev/null +++ b/frontend/src/lib/userColors.ts @@ -0,0 +1,31 @@ +import { writable } from 'svelte/store'; + +const KEY = 'relay_user_colors'; + +function load(): Record { + try { + return JSON.parse(localStorage.getItem(KEY) ?? '{}'); + } catch { + return {}; + } +} + +const { subscribe, update } = writable>(load()); + +export const userColors = { + subscribe, + set(username: string, color: string) { + update(m => { + const next = { ...m, [username]: color }; + localStorage.setItem(KEY, JSON.stringify(next)); + return next; + }); + }, + clear(username: string) { + update(m => { + const { [username]: _removed, ...rest } = m; + localStorage.setItem(KEY, JSON.stringify(rest)); + return rest; + }); + }, +}; diff --git a/frontend/src/lib/ws/attachments.ts b/frontend/src/lib/ws/attachments.ts new file mode 100644 index 0000000..506d749 --- /dev/null +++ b/frontend/src/lib/ws/attachments.ts @@ -0,0 +1,370 @@ +// Attachment upload/download helpers built on the chunked binary protocol. +// +// Upload: GetMaxChunkSize → SendMessage{attachments} → (MessageCreated gives +// attachment_ids) → stream binary chunks → AttachmentComplete. +// Download: DownloadAttachment → binary _BinaryChunk frames in seq order → +// AttachmentEnd. The server only serves downloads once is_complete. + +import { writable } from 'svelte/store'; +import { send, on, sendChunk } from './index'; +import type { AttachmentSummary, NewMessageAttachment } from './types'; + +// ── Upload progress (sender-side) ───────────────────────────────────────────── +// +// Per-attachment status while an upload is in flight. 'uploading' means chunks +// have been streamed and we're waiting for the server's AttachmentComplete ack; +// 'complete' is shown briefly once that ack lands, then the entry is dropped. +// Only the uploader observes this — receivers never get AttachmentComplete. + +export type UploadStatus = 'uploading' | 'complete' | 'error'; + +export interface UploadState { + status: UploadStatus; + reason?: string; // set when status === 'error' (the server's rejection reason) +} + +const _uploads = writable>({}); +export const uploadProgress = { subscribe: _uploads.subscribe }; + +function setUploadState(id: string, state: UploadState) { + _uploads.update((m) => ({ ...m, [id]: state })); +} +function clearUploadStatus(id: string) { + _uploads.update((m) => { + const next = { ...m }; + delete next[id]; + return next; + }); +} + +const COMPLETION_TIMEOUT_MS = 30000; +const FILE_READ_TIMEOUT_MS = 15000; +const MESSAGE_CREATED_TIMEOUT_MS = 10000; + +// Resolve an in-flight upload's indicator: 'complete' on the server's ack, or +// 'error' if the server rejects the file by content-type policy. Both states are +// transient; rejected attachments are removed from the room view by the message +// event handlers, so this only covers any short-lived upload UI still mounted. +function watchCompletion(id: string) { + let done = false; + const settle = (state: UploadState, ttl: number) => { + if (done) return; + done = true; + offComplete(); + offRejected(); + clearTimeout(timer); + setUploadState(id, state); + setTimeout(() => clearUploadStatus(id), ttl); + }; + const offComplete = on('AttachmentComplete', ({ attachment_id }) => { + if (attachment_id === id) settle({ status: 'complete' }, 1500); + }); + const offRejected = on('AttachmentRejected', ({ attachment_id, reason }) => { + if (attachment_id !== id) return; + // The server has cancelled the upload and deleted the row, so the file is + // gone server-side. Drop the sender's local-preview blob too, otherwise + // getAttachmentUrl would keep serving it from cache. + dropLocalAttachment(id); + settle({ status: 'error', reason }, 1500); + }); + const timer = setTimeout(() => settle({ status: 'complete' }, 1500), COMPLETION_TIMEOUT_MS); +} + +// ── Max chunk size (fetched once, cached) ───────────────────────────────────── + +let maxChunkPromise: Promise | null = null; + +function getMaxChunkBytes(): Promise { + if (maxChunkPromise) return maxChunkPromise; + maxChunkPromise = new Promise((resolve) => { + const off = on('MaxChunkSize', ({ bytes }) => { + off(); + resolve(bytes); + }); + send('GetMaxChunkSize'); + }); + return maxChunkPromise; +} + +// Web Crypto is unavailable in non-secure browser contexts, which commonly +// includes phones opening the app over http:// on a LAN address. +async function sha256Bytes(buf: ArrayBuffer): Promise { + try { + const subtle = globalThis.crypto?.subtle; + if (subtle) { + const digest = await subtle.digest('SHA-256', buf); + return Array.from(new Uint8Array(digest)); + } + } catch { + // Fall through to the local implementation below. + } + + return sha256Fallback(new Uint8Array(buf)); +} + +function sha256Fallback(bytes: Uint8Array): number[] { + const k = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const bitLenHigh = Math.floor(bytes.length / 0x20000000); + const bitLenLow = (bytes.length << 3) >>> 0; + const paddedLen = (((bytes.length + 9 + 63) >> 6) << 6); + const padded = new Uint8Array(paddedLen); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(paddedLen - 8, bitLenHigh, false); + view.setUint32(paddedLen - 4, bitLenLow, false); + + const w = new Uint32Array(64); + for (let offset = 0; offset < paddedLen; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + s1 + ch + k[i] + w[i]) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (s0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + [h0, h1, h2, h3, h4, h5, h6, h7].forEach((word, i) => outView.setUint32(i * 4, word, false)); + return Array.from(out); +} + +function rotr(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)); +} + +// ── Object-URL cache (attachment_id → blob URL) ─────────────────────────────── + +const urlCache = new Map(); + +// Sender-side instant preview: register the local file under its attachment_id +// so rendering skips the download round-trip (and works before is_complete). +export function registerLocalAttachment(attachmentId: string, file: Blob): string { + const url = URL.createObjectURL(file); + urlCache.set(attachmentId, url); + return url; +} + +// Forget (and revoke) a cached object URL — used when an upload is rejected so the +// sender's instant-preview blob can't keep masquerading as a downloadable file. +export function dropLocalAttachment(attachmentId: string): void { + const url = urlCache.get(attachmentId); + if (url) { + URL.revokeObjectURL(url); + urlCache.delete(attachmentId); + } +} + +// ── Upload ──────────────────────────────────────────────────────────────────── + +// Send a message carrying a single file attachment. The caller (Room) keeps its +// normal MessageCreated handling for appending the message; this only owns the +// SendMessage and the chunk streaming. +export async function uploadAttachment( + roomName: string, + content: string, + file: File, +): Promise { + const maxBytes = await getMaxChunkBytes(); + const buf = await readFileBuffer(file); + const bytes = new Uint8Array(buf); + const sha = await sha256Bytes(buf); + + const size = bytes.byteLength; + if (size === 0) throw new Error('empty attachment'); + const chunkCount = Math.max(1, Math.ceil(size / maxBytes)); + + const meta: NewMessageAttachment = { + filename: attachmentFilename(file), + content_type: file.type || 'application/octet-stream', + size_bytes: size, + chunk_count: chunkCount, + content_sha256: sha, + }; + + return new Promise((resolve, reject) => { + let done = false; + const finish = (err?: Error) => { + if (done) return; + done = true; + offCreated(); + offFailed(); + offError(); + clearTimeout(timer); + if (err) reject(err); + else resolve(); + }; + + // The next MessageCreated with a non-empty attachment list is ours: sends + // are serialized over one socket and the server acks in order. + const offCreated = on('MessageCreated', ({ attachment_ids }) => { + if (!attachment_ids || attachment_ids.length === 0) return; + const id = attachment_ids[0]; + // Instant local preview for the sender. + registerLocalAttachment(id, file); + // Mark in-flight and watch for the server's completion/rejection. + setUploadState(id, { status: 'uploading' }); + watchCompletion(id); + // Stream the chunks. + for (let seq = 0; seq < chunkCount; seq++) { + const start = seq * maxBytes; + const end = Math.min(start + maxBytes, size); + sendChunk(id, seq, bytes.subarray(start, end)); + } + finish(); + }); + const offFailed = on('Failed', () => finish(new Error('message rejected'))); + const offError = on('Error', ({ error }) => finish(new Error(error || 'message rejected'))); + const timer = setTimeout( + () => finish(new Error('message creation timed out')), + MESSAGE_CREATED_TIMEOUT_MS, + ); + + send({ SendMessage: { room_name: roomName, content, attachments: [meta] } }); + }); +} + +function readFileBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('attachment read timed out')), + FILE_READ_TIMEOUT_MS, + ); + file.arrayBuffer() + .then((buf) => { + clearTimeout(timer); + resolve(buf); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function attachmentFilename(file: File): string { + if (file.name) return file.name; + if (file.type === 'image/gif') return 'pasted.gif'; + if (file.type === 'image/png') return 'pasted.png'; + if (file.type === 'image/jpeg') return 'pasted.jpg'; + if (file.type === 'image/webp') return 'pasted.webp'; + if (file.type.startsWith('text/')) return 'pasted.txt'; + return 'attachment'; +} + +// ── Download ────────────────────────────────────────────────────────────────── + +const DOWNLOAD_TIMEOUT_MS = 8000; + +// Resolve to an object URL for the attachment's bytes. Returns the cached URL +// (including the sender's local preview) immediately when present. +// +// The download is attempted regardless of the snapshot's is_complete flag: a +// receiver's live message arrives before the upload finishes and the server +// never pushes a completion update, so the flag is unreliable. The server only +// streams bytes once the row is complete and otherwise replies with Error; this +// rejects on that Error (or timeout) so the caller can retry with backoff. +export function getAttachmentUrl(att: AttachmentSummary): Promise { + const cached = urlCache.get(att.attachment_id); + if (cached) return Promise.resolve(cached); + + return new Promise((resolve, reject) => { + const chunks = new Map(); + let settled = false; + + function cleanup() { + offChunk(); + offEnd(); + offErr(); + clearTimeout(timer); + } + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('download timed out')); + }, DOWNLOAD_TIMEOUT_MS); + + const offChunk = on('_BinaryChunk', (frame) => { + if (frame.attachment_id !== att.attachment_id) return; + chunks.set(frame.seq, frame.data); + }); + + const offEnd = on('AttachmentEnd', ({ attachment_id }) => { + if (attachment_id !== att.attachment_id || settled) return; + settled = true; + cleanup(); + const ordered = [...chunks.keys()].sort((a, b) => a - b).map((k) => chunks.get(k)!); + const blob = new Blob(ordered as BlobPart[], { type: att.content_type }); + const url = URL.createObjectURL(blob); + urlCache.set(att.attachment_id, url); + resolve(url); + }); + + // Error carries no attachment_id; with the single-image-per-message flow used + // here, an Error during a pending download means this one isn't ready yet. + const offErr = on('Error', () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('attachment not ready')); + }); + + send({ DownloadAttachment: { attachment_id: att.attachment_id } }); + }); +} diff --git a/frontend/src/lib/ws/connection.ts b/frontend/src/lib/ws/connection.ts new file mode 100644 index 0000000..f102e33 --- /dev/null +++ b/frontend/src/lib/ws/connection.ts @@ -0,0 +1,173 @@ +import type { ClientCommand, ConnectionState, ServerEventMap } from './types'; + +type AnyHandler = (payload: any) => void; +type Listeners = Partial>>; + +const BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000]; + +export class RelayConnection { + private url: string; + private ws: WebSocket | null = null; + private listeners: Listeners = {}; + private stateListeners = new Set<(s: ConnectionState) => void>(); + private state: ConnectionState = 'disconnected'; + private attempt = 0; + private timer: ReturnType | null = null; + // Set to true when a Close JSON event is received from the server, or when + // disconnect() is called manually. Prevents the reconnect loop from firing + // on those expected closes. + private expectingClose = false; + + constructor(url: string) { + this.url = url; + this.open(); + } + + private open() { + this.expectingClose = false; + this.setState('connecting'); + const ws = new WebSocket(this.url); + ws.binaryType = 'arraybuffer'; + this.ws = ws; + + ws.onopen = () => { + this.attempt = 0; + this.setState('connected'); + }; + + ws.onmessage = (ev) => { + if (ev.data instanceof ArrayBuffer) { + this.handleBinary(ev.data); + } else { + this.handleText(ev.data as string); + } + }; + + ws.onclose = () => { + this.ws = null; + if (this.expectingClose) { + this.setState('disconnected'); + return; + } + this.scheduleReconnect(); + }; + + // onerror is always followed by onclose; nothing extra to do here. + ws.onerror = () => {}; + } + + private setState(s: ConnectionState) { + this.state = s; + this.stateListeners.forEach((fn) => fn(s)); + } + + private scheduleReconnect() { + const delay = BACKOFF[Math.min(this.attempt, BACKOFF.length - 1)]; + this.attempt++; + this.setState('connecting'); + this.timer = setTimeout(() => this.open(), delay); + } + + private handleText(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error('relay: unparseable server message', raw); + return; + } + + if (typeof parsed === 'string') { + // Unit variant: e.g. "AuthOk", "Success" + const name = parsed as keyof ServerEventMap; + if (name === 'Close' as any) this.expectingClose = true; // shouldn't happen (Close has payload), but guard anyway + this.emit(name, undefined); + } else if (parsed !== null && typeof parsed === 'object') { + const keys = Object.keys(parsed as object); + if (keys.length === 1) { + const name = keys[0] as keyof ServerEventMap; + const payload = (parsed as Record)[keys[0]]; + // Server-initiated close: don't reconnect when the socket closes. + if (name === 'Close') this.expectingClose = true; + this.emit(name, payload); + } + } + } + + private handleBinary(buf: ArrayBuffer) { + // Frame: [attachment_id 16B][seq u32 BE 4B][payload...] + // Server requires payload length > 0, so minimum valid frame is 21 bytes. + if (buf.byteLength <= 20) return; + const bytes = new Uint8Array(buf); + const attachment_id = bytesToUuid(bytes.subarray(0, 16)); + const seq = new DataView(buf).getUint32(16, false); + const data = bytes.slice(20); + // Typed construction keeps the payload shape checked before the loose emit. + const chunk: ServerEventMap['_BinaryChunk'] = { attachment_id, seq, data }; + this.emit('_BinaryChunk', chunk); + } + + // Deserialized server data is untyped at runtime: handleText derives the event + // name dynamically, so the payload is necessarily `unknown` here. Subscribers + // get the precise payload type through the generic `on()`; stored handlers are + // `AnyHandler`, so dispatching `unknown` is sound. + private emit(event: keyof ServerEventMap, payload: unknown) { + this.listeners[event]?.forEach((fn) => fn(payload)); + } + + on( + event: K, + handler: (payload: ServerEventMap[K]) => void, + ): () => void { + if (!this.listeners[event]) this.listeners[event] = new Set(); + (this.listeners[event] as Set).add(handler as AnyHandler); + return () => (this.listeners[event] as Set | undefined)?.delete(handler as AnyHandler); + } + + onStateChange(fn: (s: ConnectionState) => void): () => void { + this.stateListeners.add(fn); + fn(this.state); + return () => this.stateListeners.delete(fn); + } + + send(cmd: ClientCommand) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(cmd)); + } + } + + // Send one attachment chunk. Frame: [uuid 16B][seq u32 BE 4B][payload]. + sendChunk(attachmentId: string, seq: number, data: Uint8Array) { + if (this.ws?.readyState !== WebSocket.OPEN) return; + const buf = new ArrayBuffer(20 + data.byteLength); + const view = new DataView(buf); + const bytes = new Uint8Array(buf); + bytes.set(uuidToBytes(attachmentId), 0); + view.setUint32(16, seq, false); + bytes.set(data, 20); + this.ws.send(buf); + } + + disconnect() { + this.expectingClose = true; + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + this.ws?.close(); + } +} + +function bytesToUuid(bytes: Uint8Array): string { + const h = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +function uuidToBytes(uuid: string): Uint8Array { + const hex = uuid.replace(/-/g, ''); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return bytes; +} diff --git a/frontend/src/lib/ws/index.ts b/frontend/src/lib/ws/index.ts new file mode 100644 index 0000000..df35ad3 --- /dev/null +++ b/frontend/src/lib/ws/index.ts @@ -0,0 +1,117 @@ +import { writable, derived, type Readable } from 'svelte/store'; +import { RelayConnection } from './connection'; +import type { ClientCommand, ConnectionState, ServerEventMap, RoomUnread } from './types'; + +export type { ClientCommand, ServerEventMap, ConnectionState }; +export type { + MessageHistoryItem, + AttachmentSummary, + ReactionSummary, + RoomUnread, + PublicUser, + RoomMember, + UserDirectoryEntry, + JoinRequestInfo, + NewMessageAttachment, + BinaryChunk, + DiscoverableRoom, +} from './types'; + +// ── Reactive state ──────────────────────────────────────────────────────────── + +const _state = writable('disconnected'); +const _username = writable(null); +const _isAdmin = writable(false); +const _unreadRooms = writable([]); +const _signupsOpen = writable(false); + +export const connectionState: Readable = _state; +export const currentUsername: Readable = _username; +export const authed: Readable = derived(_username, ($u) => $u !== null); +// Whether the logged-in user is an admin. Set from the AuthOk payload at login, +// cleared on any session termination. +export const isAdmin: Readable = _isAdmin; +export const unreadRooms: Readable = _unreadRooms; +// Whether the server has open signups. Queried automatically once the socket +// connects (pre-auth), so the login screen knows whether to offer registration. +export const signupsOpen: Readable = _signupsOpen; + +// ── Connection singleton ────────────────────────────────────────────────────── + +let conn: RelayConnection | null = null; +let unreadRefreshTimer: ReturnType | null = null; + +function requestUnreadSummarySoon() { + if (unreadRefreshTimer !== null) clearTimeout(unreadRefreshTimer); + unreadRefreshTimer = setTimeout(() => { + unreadRefreshTimer = null; + conn?.send('GetUnreadSummary'); + }, 100); +} + +export function connect(url = `ws://${location.host}/ws`) { + conn?.disconnect(); + if (unreadRefreshTimer !== null) { + clearTimeout(unreadRefreshTimer); + unreadRefreshTimer = null; + } + conn = new RelayConnection(url); + conn.onStateChange((s) => { + _state.set(s); + // Ask whether signups are open as soon as the socket is up (works pre-auth). + if (s === 'connected') conn?.send('GetSignupStatus'); + }); + conn.on('SignupStatus', ({ open_signups }) => _signupsOpen.set(open_signups)); + // Capture admin status from the login ack. + conn.on('AuthOk', ({ is_admin }) => { + _isAdmin.set(is_admin); + requestUnreadSummarySoon(); + }); + conn.on('UnreadSummary', ({ rooms }) => _unreadRooms.set(rooms)); + conn.on('NewMessage', requestUnreadSummarySoon); + conn.on('MessageCreated', requestUnreadSummarySoon); + // Clear auth on any server-initiated session termination. + conn.on('NoAuth', () => { _username.set(null); _isAdmin.set(false); _unreadRooms.set([]); }); + conn.on('Close', () => { _username.set(null); _isAdmin.set(false); _unreadRooms.set([]); }); +} + +export function disconnect() { + if (unreadRefreshTimer !== null) { + clearTimeout(unreadRefreshTimer); + unreadRefreshTimer = null; + } + conn?.disconnect(); + conn = null; + _username.set(null); + _isAdmin.set(false); + _unreadRooms.set([]); + _state.set('disconnected'); +} + +// ── Auth helpers ────────────────────────────────────────────────────────────── + +// Call immediately after sending Auth so the store reflects the logged-in user +// once AuthOk arrives. The caller owns deciding when auth is confirmed. +export function setCurrentUser(username: string | null) { + _username.set(username); +} + +// ── Messaging ───────────────────────────────────────────────────────────────── + +export function send(cmd: ClientCommand) { + conn?.send(cmd); +} + +export function sendChunk(attachmentId: string, seq: number, data: Uint8Array) { + conn?.sendChunk(attachmentId, seq, data); +} + +// ── Event subscriptions ─────────────────────────────────────────────────────── + +// Returns an unsubscribe function. Safe to call before connect(); returns a no-op. +export function on( + event: K, + handler: (payload: ServerEventMap[K]) => void, +): () => void { + return conn?.on(event, handler) ?? (() => {}); +} diff --git a/frontend/src/lib/ws/types.ts b/frontend/src/lib/ws/types.ts new file mode 100644 index 0000000..390f391 --- /dev/null +++ b/frontend/src/lib/ws/types.ts @@ -0,0 +1,177 @@ +// Types mirror the Rust model exactly so JSON round-trips without transforms. + +export interface MessageHistoryItem { + message_id: string; + sender_username: string; + content: string; + timestamp: string; + attachments: AttachmentSummary[]; + reactions: ReactionSummary[]; +} + +export interface AttachmentSummary { + attachment_id: string; + filename: string; + content_type: string; + size_bytes: number; + is_complete: boolean; +} + +export interface ReactionSummary { + emoji: string; + count: number; + reacted_by_me: boolean; +} + +export interface RoomUnread { + room_name: string; + unread: number; +} + +export interface PublicUser { + first_name: string | null; + last_name: string | null; + alias: string | null; + username: string; + created_at: string; +} + +// One entry in a GetUsers directory page. `is_admin` is present only when the +// requester is an admin (the admin pane); for a regular caller it's omitted, so +// treat `undefined` as "unknown / not exposed", distinct from `false`. +export interface UserDirectoryEntry { + first_name: string | null; + last_name: string | null; + alias: string | null; + username: string; + created_at: string; + is_admin?: boolean; +} + +// A room member: public profile fields plus whether they own the room. +export interface RoomMember { + first_name: string | null; + last_name: string | null; + alias: string | null; + username: string; + created_at: string; + is_owner: boolean; +} + +export interface JoinRequestInfo { + room_name: string; + username: string; +} + +export interface DiscoverableRoom { + room_name: string; + is_public: boolean; + member_count: number; +} + +export interface NewMessageAttachment { + filename: string; + content_type: string; + size_bytes: number; + chunk_count: number; + content_sha256: number[]; +} + +// Parsed from a binary WebSocket frame: [uuid 16B][seq u32 BE 4B][payload]. +export interface BinaryChunk { + attachment_id: string; + seq: number; + data: Uint8Array; +} + +// Matches Rust serde enum serialization: +// - unit variants → plain string e.g. "GetMaxChunkSize" +// - struct variants → {"VariantName": {fields}} +export type ClientCommand = + | 'GetMaxChunkSize' + | 'GetUnreadSummary' + | 'GetMyJoinRequests' + | 'GetIncomingJoinRequests' + | 'GetMyInvites' + | 'RestartServer' + | 'ShutdownServer' + | 'Close' + | { Auth: { username: string; password: string } } + | { Echo: { string: string } } + | { SendMessage: { room_name: string; content: string; attachments?: NewMessageAttachment[] } } + | { DownloadAttachment: { attachment_id: string } } + | { AddReaction: { message_id: string; emoji: string } } + | { RemoveReaction: { message_id: string; emoji: string } } + | { DeleteMessage: { message_id: string } } + | { GetMessages: { room_name: string; before?: string; limit?: number } } + | { MarkRead: { room_name: string; up_to_message_id: string } } + | { NewUser: { username: string; password: string; first_name?: string; last_name?: string; alias?: string } } + | { GetUserByUsername: { username: string } } + | { GetUsers: { starts_with?: string; after?: string; limit?: number } } + | { EditUser: { target_username: string; username?: string; first_name?: string; last_name?: string; alias?: string } } + | { Promote: { target_username: string } } + | { Demote: { target_username: string } } + | { DeleteUser: { target_username: string } } + | { UpdatePassword: { current_password: string; new_password: string } } + | { ResetPassword: { target_username: string; new_password: string } } + | { NewRoom: { room_name: string; is_public?: boolean; is_discoverable?: boolean } } + | { AddRoomOwner: { room_name: string; new_owner_username: string } } + | { SetRoomName: { current_name: string; new_name: string } } + | { GetRoomMembership: { room_name: string } } + | { GetRoom: { room_name: string } } + | { JoinRoom: { room_name: string } } + | { LeaveRoom: { room_name: string } } + | { RemoveRoomMember: { room_name: string; member_username: string } } + | { CancelJoinRequest: { room_name: string } } + | { ApproveJoinRequest: { room_name: string; requester_username: string } } + | { RejectJoinRequest: { room_name: string; requester_username: string } } + | { InviteToRoom: { room_name: string; invitee_username: string } } + | { AcceptInvite: { room_name: string } } + | { DeclineInvite: { room_name: string } } + | 'ListDiscoverableRooms' + | 'ListAllRooms' + | 'GetSignupStatus' + | { Error: { error: string } }; + +// Maps every ServerEvent variant name to its payload type. +// void = unit variant (no payload). Used to type the event emitter. +export interface ServerEventMap { + AuthOk: { is_admin: boolean }; + NoAuth: void; + UserCreated: void; + NoChange: void; + NoUserExists: void; + NoRoomExists: void; + RoomCreated: void; + JoinRequested: void; + Success: void; + Failed: void; + Echo: { string: string }; + MessageCreated: { message_id: string; attachment_ids: string[]; message: MessageHistoryItem }; + AttachmentComplete: { attachment_id: string }; + AttachmentRejected: { attachment_id: string; reason: string }; + MaxChunkSize: { bytes: number }; + SignupStatus: { open_signups: boolean }; + AttachmentEnd: { attachment_id: string }; + MessageHistory: { room_name: string; messages: MessageHistoryItem[] }; + UnreadSummary: { rooms: RoomUnread[] }; + NewMessage: { room_name: string; message: MessageHistoryItem }; + MessageRemoved: { room_name: string; message_id: string }; + Resync: { room_name: string }; + Close: { reason: string }; + Error: { error: string }; + RateLimit: { error: string }; + UserInfo: { first_name: string | null; last_name: string | null; alias: string | null; username: string; created_at: string }; + RoomMembers: { members: RoomMember[] }; + RoomInfo: { room_name: string; is_public: boolean; is_discoverable: boolean }; + MyJoinRequests: { rooms: string[] }; + IncomingJoinRequests: { requests: JoinRequestInfo[] }; + MyInvites: { rooms: string[] }; + DiscoverableRooms: { rooms: DiscoverableRoom[] }; + AllRooms: { rooms: DiscoverableRoom[] }; + Users: { users: UserDirectoryEntry[]; has_more: boolean }; + // Synthetic: emitted for incoming binary frames rather than JSON events. + _BinaryChunk: BinaryChunk; +} + +export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; diff --git a/frontend/src/lib/ws/users.ts b/frontend/src/lib/ws/users.ts new file mode 100644 index 0000000..f02cbbe --- /dev/null +++ b/frontend/src/lib/ws/users.ts @@ -0,0 +1,57 @@ +// User-directory (GetUsers) helpers. +// +// The directory is paged: each request returns at most `limit` entries, ordered +// by username, plus `hasMore`. Continue by passing the last entry's `username` as +// the next `after` cursor. An optional `startsWith` prefix filters by username +// (case-insensitive). Open to any authenticated user; `is_admin` is only present +// on entries when the caller is themselves an admin. + +import { send, on } from './index'; +import type { UserDirectoryEntry } from './types'; + +export type { UserDirectoryEntry }; + +export interface UserPage { + users: UserDirectoryEntry[]; + hasMore: boolean; +} + +export interface GetUsersOpts { + startsWith?: string; + after?: string; + limit?: number; +} + +const REQUEST_TIMEOUT_MS = 8000; + +// Fetch one page of the directory. Resolves on the next `Users` reply. Replies are +// serialized over the socket, so for sequential, awaited paging (the admin +// directory) each call maps to its own response. For rapid typeahead use, debounce +// and guard with a sequence number so only the latest query's result is applied — +// see the invite search in RoomInfoPanel. +export function getUsers(opts: GetUsersOpts = {}): Promise { + const startsWith = opts.startsWith?.trim() || undefined; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + offUsers(); + offFailed(); + clearTimeout(timer); + fn(); + }; + + const offUsers = on('Users', ({ users, has_more }) => + finish(() => resolve({ users, hasMore: has_more })), + ); + // GetUsers only fails on an internal error; surface it so callers don't hang. + const offFailed = on('Failed', () => finish(() => reject(new Error('user lookup failed')))); + const timer = setTimeout( + () => finish(() => reject(new Error('user lookup timed out'))), + REQUEST_TIMEOUT_MS, + ); + + send({ GetUsers: { starts_with: startsWith, after: opts.after, limit: opts.limit } }); + }); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..664a057 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..b0683fd --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dfc2d07 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{svelte,ts}'], + darkMode: 'class', + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..df56300 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..408b690 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..649b50f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + server: { + host: true, // listen on 0.0.0.0 so LAN devices can reach the Vite dev server + proxy: { + '/ws': { + // Use IPv4 explicitly. On some systems Node resolves localhost to ::1, + // while the Rust backend is bound on IPv4 (0.0.0.0:3000 by default). + target: 'ws://127.0.0.1:3000', + ws: true, + }, + }, + }, +}) diff --git a/src/app.rs b/src/app.rs index 6150cf1..1918e8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,12 +8,13 @@ use sqlx::PgPool; use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; +use tower_http::services::{ServeDir, ServeFile}; use crate::{ auth::AuthHandle, config::Config, control::ServerControl, - handler::{index, no_content, ok, script, ws_handler}, + handler::{embedded_asset, no_content, ok, ws_handler}, hub::Hub, message::{self, MessageHandle}, room::{self, RoomHandle}, @@ -32,9 +33,6 @@ pub struct AppState { pub(crate) write_semaphore: Arc, pub(crate) download_semaphore: Arc, pub(crate) hub: Hub, - // Lifecycle control: lets an authorized in-app action (the admin RestartServer / - // ShutdownServer commands) ask the supervisor in `main` to restart or shut the - // process down. Reachable wherever AppState is. pub(crate) control: ServerControl, } @@ -53,8 +51,6 @@ pub async fn app( .unwrap(), ); - // ACTOR SPAWN AND HANDLE - // AuthHandle was spawned in calling function let hub = Hub::new(); let user_handle = user::spawn(shutdown.clone(), pool.clone()).await; let room_handle = room::spawn(shutdown.clone(), pool.clone(), hub.clone()).await; @@ -67,6 +63,9 @@ pub async fn app( crate::attachment::MAX_CONCURRENT_CHUNK_READS, )); + // Extract before config is moved into Arc. + let frontend_dir = config.frontend_dir.clone(); + let state = AppState { shutdown: shutdown.clone(), config: Arc::new(config), @@ -81,12 +80,20 @@ pub async fn app( control, }; - Router::new() - .route("/", get(index)) + let router = Router::new() .route("/health", get(ok)) - .route("/script.js", get(script)) .route("/favicon.ico", get(no_content)) .route("/ws", any(ws_handler)) .layer(GovernorLayer::new(governor_conf)) - .with_state(state) + .with_state(state); + + // When FRONTEND_DIR is set, serve from the filesystem (community or dev + // override). Otherwise serve the embedded default frontend. Both paths fall + // back to index.html for unmatched routes so client-side routing works. + if let Some(dir) = frontend_dir { + router + .fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(dir.join("index.html")))) + } else { + router.fallback(embedded_asset) + } } diff --git a/src/attachment.rs b/src/attachment.rs index 5819240..0d66bd3 100644 --- a/src/attachment.rs +++ b/src/attachment.rs @@ -9,6 +9,7 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use uuid::Uuid; +use crate::hub::Hub; use crate::model::ServerEvent; // Max chunk writes in flight across all uploads, so an upload burst can't drain @@ -34,6 +35,75 @@ pub struct Chunk { pub data: Vec, } +// #### Content-type policy #### +// +// A completed upload is checked against a magic-byte sniff of its own contents +// before it is published, so the stored content_type is trustworthy regardless of +// what the client declared. Detection is authoritative: a mislabeled but supported +// file is corrected to its real type, and an unsupported or smuggled file is +// rejected outright. +// +// Types whose bytes carry a recognizable magic signature. infer detects these; the +// detected type is stored verbatim, overriding any (possibly false) declaration. +const SUPPORTED_BINARY_TYPES: &[&str] = &[ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "application/zip", +]; + +// Text-like types that have no magic signature (infer returns nothing), so the +// specific type can't be detected and the client's declaration is the only source. +// Allowed only when the declared type is one of these AND the bytes don't look +// binary, which keeps arbitrary binaries from being smuggled in as "text". +const SUPPORTED_TEXT_TYPES: &[&str] = &[ + "text/plain", + "text/csv", + "text/markdown", + "application/json", + "image/svg+xml", +]; + +// Resolve the content_type to store for a completed upload, sniffing its leading +// bytes. Ok(type) is the authoritative type to persist (the detected type for +// magic formats; the declared type for magicless text). Err carries a short, +// client-safe rejection reason. +fn resolve_content_type(declared: &str, header: &[u8]) -> Result { + // Normalize: drop any parameters (e.g. "; charset=utf-8") and lowercase. + let declared = declared + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + + match infer::get(header) { + Some(kind) => { + let detected = kind.mime_type(); + if !SUPPORTED_BINARY_TYPES.contains(&detected) { + return Err(format!("unsupported file type ({detected})")); + } + // Detection wins: store the true type, correcting any mislabeling. + Ok(detected.to_owned()) + } + None => { + // No magic signature -> only the declared text-like allowlist is valid, + // and a NUL byte in the header means it isn't really text. + if !SUPPORTED_TEXT_TYPES.contains(&declared.as_str()) { + return Err(format!( + "unsupported or unrecognized file type ({declared})" + )); + } + if header.contains(&0u8) { + return Err("declared a text type but the content is binary".to_owned()); + } + Ok(declared) + } + } +} + // Session-held handle to a single in-flight upload actor. Bound to one attachment: // it can only ever feed chunks to that attachment's actor. pub struct AttachmentHandle { @@ -59,9 +129,11 @@ pub fn spawn( chunk_count: i32, size_bytes: i64, content_sha256: Vec, + content_type: String, pool: PgPool, write_semaphore: Arc, user_tx: mpsc::Sender, + hub: Hub, shutdown: CancellationToken, ) -> AttachmentHandle { let (tx, mut rx) = mpsc::channel::(8); @@ -139,15 +211,55 @@ pub fn spawn( // hasher (no reassembly), checking total size and the declared digest. match verify(&pool, attachment_id, size_bytes, &content_sha256).await { Ok(true) => { + // Bytes match the declared hash/size; now resolve the file's + // true format. Magic detection only reads the header, so the + // leading bytes (chunk 0) are enough -- no full reassembly. + let header = match sniff_header(&pool, attachment_id).await { + Ok(header) => header, + Err(e) => { + tracing::error!(error = %e, %attachment_id, "upload: header sniff query failed"); + break; + } + }; + + let final_type = match resolve_content_type(&content_type, &header) { + Ok(t) => t, + Err(reason) => { + // Unsupported format or a type-mismatch we won't accept. + // Cancel the upload: delete the attachment (chunks cascade) + // and, if that leaves its message with nothing attached, + // delete the message too and fan removal details out to the + // room -- otherwise it lingers as a bare filename or dead + // attachment for every member. Then tell the uploader exactly + // what failed and why. + tracing::warn!(%attachment_id, %reason, "upload: rejected by content-type policy"); + let fanned_out = + cancel_rejected_upload(&pool, &hub, attachment_id, reason.clone()) + .await; + if !fanned_out { + let _ = user_tx + .send(ServerEvent::AttachmentRejected { + attachment_id, + reason, + }) + .await; + } + break; + } + }; + // CAS flip: exactly one path wins the monotonic false->true and - // announces completion; a racing actor gets 0 rows and is silent. + // announces completion, correcting the stored type to the + // detected one in the same statement; a racing actor gets 0 rows + // and is silent. let won = sqlx::query_scalar::<_, Uuid>( "UPDATE message_attachments - SET is_complete = true + SET is_complete = true, content_type = $2 WHERE attachment_id = $1 AND NOT is_complete RETURNING attachment_id", ) .bind(attachment_id) + .bind(&final_type) .fetch_optional(&pool) .await; @@ -288,6 +400,112 @@ pub fn download( }); } +// Read the attachment's leading bytes (chunk 0) for magic-byte sniffing. Magic +// detection only inspects a file's header, so the first chunk is always enough +// and the rest of the file never has to be reassembled in memory. +async fn sniff_header(pool: &PgPool, attachment_id: Uuid) -> Result, sqlx::Error> { + let header: Option> = sqlx::query_scalar( + "SELECT data FROM message_attachment_chunks + WHERE attachment_id = $1 AND seq = 0", + ) + .bind(attachment_id) + .fetch_optional(pool) + .await?; + Ok(header.unwrap_or_default()) +} + +// Roll back an upload the content-type policy rejected. Delete the attachment row +// (its chunks cascade via ON DELETE CASCADE), and if that leaves the parent +// message with no attachments at all -- the common case, since a file post's +// caption defaults to the filename, so the message is nothing but the file -- +// delete the message too and broadcast MessageRemoved so it doesn't sit in the +// room as a bare filename for every member. A message with surviving attachments +// is kept, but the rejected attachment is still fanned out to the room so live +// clients can remove that dead attachment from the message. Best-effort: a DB +// hiccup here at worst leaves an incomplete row for the reaper, never a served +// file (the row never reached is_complete). +async fn cancel_rejected_upload( + pool: &PgPool, + hub: &Hub, + attachment_id: Uuid, + reason: String, +) -> bool { + // Resolve the parent message and room before the attachment row is gone, so we + // can target the room broadcast after the deletes. + let parent = sqlx::query_as::<_, (Uuid, Uuid, String)>( + "SELECT m.message_id, m.room_id, r.room_name + FROM message_attachments a + JOIN messages m ON m.message_id = a.message_id + JOIN rooms r ON r.room_id = m.room_id + WHERE a.attachment_id = $1", + ) + .bind(attachment_id) + .fetch_optional(pool) + .await; + + if let Err(e) = sqlx::query("DELETE FROM message_attachments WHERE attachment_id = $1") + .bind(attachment_id) + .execute(pool) + .await + { + tracing::error!(error = %e, %attachment_id, "upload: failed to delete rejected attachment"); + return false; + } + + let (message_id, room_id, room_name) = match parent { + Ok(Some(row)) => row, + // Already gone (reaped or a race): the attachment is deleted, nothing to fan out. + Ok(None) => return false, + Err(e) => { + tracing::error!(error = %e, %attachment_id, "upload: parent message lookup failed"); + return false; + } + }; + + hub.publish( + room_id, + ServerEvent::AttachmentRejected { + attachment_id, + reason, + }, + ); + + // Only orphan-delete the message when the rejected file was its only attachment. + let remaining: i64 = + match sqlx::query_scalar("SELECT COUNT(*) FROM message_attachments WHERE message_id = $1") + .bind(message_id) + .fetch_one(pool) + .await + { + Ok(n) => n, + Err(e) => { + tracing::error!(error = %e, %message_id, "upload: sibling-attachment count failed"); + return true; + } + }; + if remaining > 0 { + return true; + } + + if let Err(e) = sqlx::query("DELETE FROM messages WHERE message_id = $1") + .bind(message_id) + .execute(pool) + .await + { + tracing::error!(error = %e, %message_id, "upload: failed to delete orphaned message"); + return true; + } + + hub.publish( + room_id, + ServerEvent::MessageRemoved { + room_name, + message_id, + }, + ); + true +} + // Stream the attachment's chunks in seq order, summing length and hashing, and // compare against the declared size and SHA-256. Never holds the whole file in // memory. diff --git a/src/bin/seed.rs b/src/bin/seed.rs new file mode 100644 index 0000000..a20ddb6 --- /dev/null +++ b/src/bin/seed.rs @@ -0,0 +1,234 @@ +//! Development seed — creates test users, rooms, and messages so the full +//! UI can be explored without any manual setup. +//! +//! Usage: +//! cargo run --bin seed +//! +//! Idempotent: users and rooms that already exist are skipped; messages are +//! only inserted when the messages table is empty, so a partial re-run won't +//! duplicate them. +//! +//! Credentials after seeding: +//! admin / admin123 (admin) +//! alice / password +//! bob / password +//! carol / password + +use relay::config::Config; +use relay::model::{NewCredential, NewUser, Password}; +use relay::user::{UserResponse, ensure_admin, spawn as spawn_users}; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use tokio_util::sync::CancellationToken; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + dotenvy::dotenv().ok(); + + // Use the same config the server uses so admin credentials stay in sync. + // ensure_admin is called on every server start and overwrites the password, + // so the seed must use the same values or the seeded password will be lost. + let config = Config::from_env().map_err(|e| format!("config error: {e}"))?; + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&config.database_url) + .await?; + + println!("running migrations..."); + sqlx::migrate!("./migrations").run(&pool).await?; + + println!("\n── users ─────────────────────────────────────────────"); + create_users(&pool, &config).await?; + + println!("\n── rooms ─────────────────────────────────────────────"); + create_rooms(&pool, &config.admin_username).await?; + + println!("\n── messages ──────────────────────────────────────────"); + create_messages(&pool, &config.admin_username).await?; + + println!("\ndone."); + Ok(()) +} + +async fn create_users(pool: &PgPool, config: &Config) -> Result<(), BoxError> { + // Admin — use the same username + credential the server reads from config so + // they stay in sync (ensure_admin overwrites the password on every server start). + ensure_admin( + pool.clone(), + &config.admin_username, + NewCredential { + password: Password(config.admin_credential.clone()), + }, + ) + .await?; + println!( + " {} / {} (admin)", + config.admin_username, config.admin_credential + ); + + // Regular users — spawn one actor instance for all three. + let shutdown = CancellationToken::new(); + let handle = spawn_users(shutdown.clone(), pool.clone()).await; + + for (username, password) in [ + ("alice", "password"), + ("bob", "password"), + ("carol", "password"), + ] { + let resp = handle + .new_user( + NewUser { + username: username.to_owned(), + first_name: None, + last_name: None, + alias: None, + }, + NewCredential { + password: Password(password.to_owned()), + }, + ) + .await; + + match resp { + UserResponse::UserCreated { .. } => println!(" {username} / {password}"), + _ => println!(" {username} already exists, skipping"), + } + } + + shutdown.cancel(); + Ok(()) +} + +async fn create_rooms(pool: &PgPool, admin: &str) -> Result<(), BoxError> { + // (room_name, owner, is_public, is_discoverable, extra_members) + let rooms: Vec<(&str, &str, bool, bool, &[&str])> = vec![ + // Fully public: anyone can join, appears in the discover list. + ("general", admin, true, true, &["alice", "bob", "carol"]), + // Public and discoverable: smaller group. + ("random", "alice", true, true, &["bob"]), + // Private but discoverable: visible in discover list, join requires request. + ("staff", admin, false, true, &[]), + // Private and non-discoverable: invitation-only, hidden from discover. + ("secret", "alice", false, false, &["bob"]), + ]; + + for (room_name, owner, is_public, is_discoverable, extra) in rooms { + let created: Option = sqlx::query_scalar( + "INSERT INTO rooms (room_name, is_public, is_discoverable) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + RETURNING true", + ) + .bind(room_name) + .bind(is_public) + .bind(is_discoverable) + .fetch_optional(pool) + .await?; + + if created.is_none() { + println!(" #{room_name} already exists, skipping"); + continue; + } + + sqlx::query( + "INSERT INTO memberships (room_id, user_id, is_owner) + SELECT r.room_id, u.user_id, true + FROM rooms r, users u + WHERE r.room_name = $1 AND u.username = $2 + ON CONFLICT DO NOTHING", + ) + .bind(room_name) + .bind(owner) + .execute(pool) + .await?; + + for member in extra { + sqlx::query( + "INSERT INTO memberships (room_id, user_id, is_owner) + SELECT r.room_id, u.user_id, false + FROM rooms r, users u + WHERE r.room_name = $1 AND u.username = $2 + ON CONFLICT DO NOTHING", + ) + .bind(room_name) + .bind(member) + .execute(pool) + .await?; + } + + let vis = match (is_public, is_discoverable) { + (true, _) => "public", + (false, true) => "private, discoverable", + (false, false) => "private", + }; + println!(" #{room_name} ({vis}, owner: {owner})"); + } + + Ok(()) +} + +async fn create_messages(pool: &PgPool, admin: &str) -> Result<(), BoxError> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages") + .fetch_one(pool) + .await?; + + if count > 0 { + println!(" messages table not empty, skipping"); + return Ok(()); + } + + let admin_welcome = "welcome to relay. let me know if anything looks off".to_owned(); + let staff_msg = "staff-only channel — private but appears in the discover list".to_owned(); + + // (room, sender, content) + let messages: Vec<(&str, &str, &str)> = vec![ + ("general", "alice", "hey everyone, system seems to be up"), + ("general", "bob", "nice, glad it's working"), + ("general", "carol", "hello!"), + ( + "general", + "alice", + "you can open more rooms from the directory — click the hamburger", + ), + ("general", admin, &admin_welcome), + ( + "random", + "bob", + "anyone want to chat about nothing in particular?", + ), + ("random", "alice", "always"), + ( + "random", + "bob", + "great, because i have a lot of nothing to say", + ), + ("staff", admin, &staff_msg), + ( + "secret", + "alice", + "this room is private and non-discoverable, invite-only", + ), + ("secret", "bob", "sneaky"), + ]; + + let n = messages.len(); + for (room, sender, content) in messages { + sqlx::query( + "INSERT INTO messages (room_id, sender_id, sender_username_snapshot, content) + SELECT r.room_id, u.user_id, u.username, $3 + FROM rooms r, users u + WHERE r.room_name = $1 AND u.username = $2", + ) + .bind(room) + .bind(sender) + .bind(content) + .execute(pool) + .await?; + } + + println!(" {n} messages inserted"); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 662ac80..953cd56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::Deserialize; use crate::logging::LogFormat; @@ -32,6 +34,10 @@ pub struct Config { pub max_chunk_bytes: usize, pub admin_username: String, pub admin_credential: String, + // Override the embedded default frontend by pointing at a directory of static + // files. Any path served by the embedded frontend is shadowed by this directory + // instead. Unset means the binary's built-in frontend is used. + pub frontend_dir: Option, // Filter directive used when RUST_LOG is unset (e.g. "info", "relay=debug"). // RUST_LOG, when present, always takes precedence. #[serde(default = "default_log_level")] diff --git a/src/handler.rs b/src/handler.rs index 6a405e6..3fab24c 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -3,13 +3,18 @@ use std::net::SocketAddr; use crate::{app::AppState, server::handle_socket}; use axum::{ extract::{ConnectInfo, State, WebSocketUpgrade}, - http::{StatusCode, header}, - response::{Html, IntoResponse}, + http::{StatusCode, Uri, header}, + response::{IntoResponse, Response}, }; use axum_extra::{ TypedHeader, headers::{self}, }; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "frontend/dist/"] +struct Assets; pub(crate) async fn ws_handler( State(state): State, @@ -24,29 +29,32 @@ pub(crate) async fn ws_handler( }; tracing::debug!(who = %addr, %user_agent, "connection accepted"); - // Pin both the message and frame size caps to the configured max chunk payload - // (plus the frame header) so the limit a client learns from GetMaxChunkSize is - // the real one -- not undercut by tungstenite's smaller default frame cap. A - // chunk over this is dropped by the transport (connection closed), so a - // well-behaved client queries the limit and stays under it. let frame_cap = state.config.max_chunk_bytes + crate::attachment::CHUNK_HEADER_LEN; ws.max_message_size(frame_cap) .max_frame_size(frame_cap) .on_upgrade(move |socket| handle_socket(socket, state, addr)) } -pub(crate) async fn index() -> Html<&'static str> { - Html(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/index.html" - ))) +pub(crate) async fn embedded_asset(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + serve_asset(path) } -pub(crate) async fn script() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "application/javascript")], - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/script.js")), - ) +fn serve_asset(path: &str) -> Response { + match Assets::get(path) { + Some(file) => { + let mime = file.metadata.mimetype(); + ([(header::CONTENT_TYPE, mime)], file.data.into_owned()).into_response() + } + None => match Assets::get("index.html") { + Some(file) => { + let mime = file.metadata.mimetype(); + ([(header::CONTENT_TYPE, mime)], file.data.into_owned()).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + }, + } } pub(crate) async fn no_content() -> StatusCode { diff --git a/src/hub.rs b/src/hub.rs index 4abd3ae..a6248d0 100644 --- a/src/hub.rs +++ b/src/hub.rs @@ -121,6 +121,26 @@ impl Hub { }); } } + + // Drop `room_id`'s live stream from every currently-connected session of + // `user_id`. The mirror of subscribe_user_to_room: used when a user is removed + // from a room by someone else (a kick), so their open sessions stop receiving + // the room's messages immediately rather than at their next reconnect. A closed + // session's send simply fails; it's cleaned up when that session's guard + // deregisters. Offline users have no sessions and re-subscribe (as a member) + // only if re-admitted. + pub fn unsubscribe_user_from_room(&self, user_id: Uuid, room_id: Uuid) { + let senders: Vec> = { + let sessions = self.sessions.lock().unwrap(); + match sessions.get(&user_id) { + Some(entries) => entries.iter().map(|e| e.sub_tx.clone()).collect(), + None => return, + } + }; + for sub_tx in senders { + let _ = sub_tx.try_send(Subscription::Remove { room_id }); + } + } } // Deregisters a session from the Hub's presence registry when dropped, so a session diff --git a/src/message.rs b/src/message.rs index 374881d..4a43c39 100644 --- a/src/message.rs +++ b/src/message.rs @@ -38,6 +38,11 @@ pub enum MessageRequest { emoji: String, tx: oneshot::Sender, }, + DeleteMessage { + user_id: Uuid, + message_id: Uuid, + tx: oneshot::Sender, + }, GetMessages { user_id: Uuid, room_name: String, @@ -172,6 +177,29 @@ impl MessageHandle { rx.await.unwrap_or(MessageResponse::Failed) } + // Delete `message_id` on behalf of `user_id`, the authenticated caller. + // Authorization (own message or admin) is enforced in the actor; on success the + // removal is fanned out to the room. A forbidden or unknown message is a generic + // failure. + pub async fn delete_message(&self, user_id: Uuid, message_id: Uuid) -> MessageResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(MessageRequest::DeleteMessage { + user_id, + message_id, + tx, + }) + .await + .is_err() + { + return MessageResponse::Failed; + } + + rx.await.unwrap_or(MessageResponse::Failed) + } + // Fetch one page of `room_name`'s history on behalf of `user_id`, the // authenticated caller. Membership is checked in the actor. `before` is a // keyset cursor (a message_id); `limit` is clamped server-side. @@ -527,6 +555,64 @@ async fn handle_request( } } + MessageRequest::DeleteMessage { + user_id, + message_id, + tx, + } => { + // JIT auth: only the message's own sender or a server admin may delete + // it. Resolve the room (id + name for the broadcast) in the same gated + // statement, so a non-author non-admin -- and an unknown message -- both + // yield no row and the same generic failure, leaking neither the + // message's existence nor who is allowed to remove it. Re-checked per + // request rather than trusting the session's connect-time is_admin. + let room = sqlx::query_as::<_, (Uuid, String)>( + "SELECT r.room_id, r.room_name + FROM messages m + JOIN rooms r ON r.room_id = m.room_id + WHERE m.message_id = $1 + AND (m.sender_id = $2 + OR EXISTS (SELECT 1 FROM admins a WHERE a.user_id = $2))", + ) + .bind(message_id) + .bind(user_id) + .fetch_optional(&pool) + .await; + + let (room_id, room_name) = match room { + Ok(Some(row)) => row, + Ok(None) => return (MessageResponse::Failed, tx), + Err(e) => { + tracing::error!(error = %e, %message_id, "delete_message: auth lookup failed"); + return (MessageResponse::Failed, tx); + } + }; + + // Remove the message; its attachments (and their chunks) and reactions + // cascade via ON DELETE CASCADE. + if let Err(e) = sqlx::query("DELETE FROM messages WHERE message_id = $1") + .bind(message_id) + .execute(&pool) + .await + { + tracing::error!(error = %e, %message_id, "delete_message: delete failed"); + return (MessageResponse::Failed, tx); + } + + // Fan the removal out so every subscribed client drops it live. The + // caller's own session gets it too and removes the message like everyone + // else (there's no rejected-upload label to preserve for a deletion). + hub.publish( + room_id, + ServerEvent::MessageRemoved { + room_name, + message_id, + }, + ); + + (MessageResponse::Success, tx) + } + MessageRequest::GetMessages { user_id, room_name, @@ -540,8 +626,8 @@ async fn handle_request( .map(|l| (l as i64).clamp(1, MAX_HISTORY_LIMIT)) .unwrap_or(DEFAULT_HISTORY_LIMIT); - // JIT auth + name resolution: members only, existence hidden. - let room_id = match resolve_member_room(&pool, &room_name, user_id).await { + // JIT auth + name resolution: members or admins, existence hidden. + let room_id = match resolve_readable_room(&pool, &room_name, user_id).await { Ok(Some(id)) => id, Ok(None) => return (MessageResponse::Failed, tx), Err(e) => { @@ -666,6 +752,29 @@ async fn resolve_member_room( .await } +// Resolve a (normalized) room name to its id for a caller who may READ it: a +// member, or an admin (who can read any room for moderation). A non-member +// non-admin and an unknown room both yield None, so neither the room's existence +// nor its membership leaks to anyone without read rights. Used by history reads. +async fn resolve_readable_room( + pool: &PgPool, + room_name: &str, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, Uuid>( + "SELECT r.room_id + FROM rooms r + WHERE LOWER(r.room_name) = LOWER(trim_ws($1)) + AND (EXISTS (SELECT 1 FROM memberships mb + WHERE mb.room_id = r.room_id AND mb.user_id = $2) + OR EXISTS (SELECT 1 FROM admins a WHERE a.user_id = $2))", + ) + .bind(room_name) + .bind(user_id) + .fetch_optional(pool) + .await +} + // Load one page of a room's messages (newest first) with their attachments and // reaction summary, given that the caller's membership was already verified. // diff --git a/src/model.rs b/src/model.rs index a68f50a..13e7216 100644 --- a/src/model.rs +++ b/src/model.rs @@ -17,7 +17,7 @@ pub enum ClientCommand { // more files whose bytes follow as binary chunk frames, each keyed by an // attachment_id the server hands back in MessageCreated. SendMessage { - room_id: Uuid, + room_name: String, content: String, #[serde(default)] attachments: Vec, @@ -50,6 +50,15 @@ pub enum ClientCommand { message_id: Uuid, emoji: String, }, + // Permanently remove a message (an "unsend"). Allowed only for the message's + // own sender or a server admin; the check is server-side per request. On + // success the message -- with any attachments, their chunks, and reactions -- + // is deleted and a MessageRemoved is fanned out to the room so every client + // drops it live. A forbidden or unknown message yields the same generic + // failure, so neither the message's existence nor who may delete it leaks. + DeleteMessage { + message_id: Uuid, + }, // Page through a room's message history, newest first, each message carrying // its attachment metadata and reaction summary. Read access requires // membership of the room; a non-member and an unknown room yield the same @@ -84,6 +93,24 @@ pub enum ClientCommand { GetUserByUsername { username: String, }, + // Page through the user directory, ordered by username. Open to any + // authenticated user (e.g. to find someone to invite without knowing their + // exact handle). Answered with `Users`. + // + // - `starts_with`: optional case-insensitive username prefix filter. Empty or + // whitespace-only is treated as no filter. + // - `after`: keyset cursor -- the `username` of the last entry from the + // previous page; the next page continues with usernames ordered after it. + // Omit for the first page. + // - `limit`: page size, clamped server-side (defaults applied, hard cap). + GetUsers { + #[serde(default)] + starts_with: Option, + #[serde(default)] + after: Option, + #[serde(default)] + limit: Option, + }, EditUser { target_username: String, username: Option, @@ -133,8 +160,17 @@ pub enum ClientCommand { LeaveRoom { room_name: String, }, + // Owner/admin removes another user's membership from a room. + RemoveRoomMember { + room_name: String, + member_username: String, + }, // The caller's own outstanding join requests. GetMyJoinRequests, + // The caller withdraws their own pending join request. + CancelJoinRequest { + room_name: String, + }, // Pending join requests for rooms the caller owns (or any room, if admin). GetIncomingJoinRequests, ApproveJoinRequest { @@ -158,6 +194,16 @@ pub enum ClientCommand { DeclineInvite { room_name: String, }, + // Whether the server has open (unauthenticated) signups enabled. Valid in the + // prelude (before auth) so a client can decide whether to offer registration, + // and also after auth. Reply: SignupStatus. + GetSignupStatus, + // Returns all rooms that are publicly listed (is_discoverable = true or is_public = true), + // so a client can show a room browser without knowing room names in advance. + ListDiscoverableRooms, + // Admin only: returns every room (including private, non-discoverable ones) so + // an admin can browse and moderate any room. Non-admins are rejected. + ListAllRooms, // Restart the entire server process: drain every connection and actor, then // re-initialize from a fresh config. Admin only. The issuing connection is torn // down with the rest, so the client should expect its socket to close shortly @@ -174,7 +220,9 @@ pub enum ClientCommand { #[derive(Serialize, Deserialize, PartialEq, Debug)] pub enum ServerEvent { - AuthOk, + AuthOk { + is_admin: bool, + }, NoAuth, Echo { string: String, @@ -193,11 +241,22 @@ pub enum ServerEvent { AttachmentComplete { attachment_id: Uuid, }, + // A fully-uploaded attachment failed the content-type policy (unsupported + // format, or declared type doesn't match the actual bytes) and was not + // published. Attachment-specific so the client can attribute it to the file. + AttachmentRejected { + attachment_id: Uuid, + reason: String, + }, // Reply to GetMaxChunkSize: the largest chunk payload (file bytes, excluding // the frame header) the server will accept in one upload frame. MaxChunkSize { bytes: usize, }, + // Reply to GetSignupStatus: whether unauthenticated account creation is open. + SignupStatus { + open_signups: bool, + }, // One chunk of a download, streamed back in seq order. The sender task emits // this as a BINARY frame -- [attachment_id 16B][seq u32 BE 4B][payload] -- and // never JSON-serializes it, so the bytes don't pay base64/array inflation on @@ -234,6 +293,14 @@ pub enum ServerEvent { room_name: String, message: MessageHistoryItem, }, + // A message was removed server-side and should be dropped from the room view. + // Emitted for unsend/admin deletion and when a rejected upload leaves a + // message with no attachments, so it would otherwise linger as a bare filename + // or caption for everyone in the room. + MessageRemoved { + room_name: String, + message_id: Uuid, + }, // The session fell behind a room's live buffer and dropped events. Not an // error -- a hint to re-fetch that room from history (GetMessages); the read // watermark keeps the unread count correct meanwhile. @@ -258,7 +325,7 @@ pub enum ServerEvent { created_at: DateTime, }, RoomMembers { - members: Vec, + members: Vec, }, RoomInfo { room_name: String, @@ -274,6 +341,20 @@ pub enum ServerEvent { MyInvites { rooms: Vec, }, + DiscoverableRooms { + rooms: Vec, + }, + // Reply to ListAllRooms (admin): every room with a live member count. + AllRooms { + rooms: Vec, + }, + // Reply to GetUsers: one page of the user directory, ordered by username. + // `has_more` is true when another page exists after this one (continue by + // passing the last entry's `username` as the next `after` cursor). + Users { + users: Vec, + has_more: bool, + }, NoChange, NoUserExists, NoRoomExists, @@ -336,6 +417,34 @@ pub struct PublicUser { pub created_at: DateTime, } +// One entry in a GetUsers directory page: the public profile fields plus an +// optional admin flag. `is_admin` is populated only when the *requesting* user is +// an admin (the admin pane's "identify other admins"); for a regular caller it is +// None and omitted from the wire, so admin status isn't exposed to non-admins. +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct UserDirectoryEntry { + pub first_name: Option, + pub last_name: Option, + pub alias: Option, + pub username: String, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_admin: Option, +} + +// A room member as shown to clients: the public profile fields plus whether the +// user is an owner of the room (memberships.is_owner), so the client can render +// ownership and gate owner-only actions. +#[derive(sqlx::FromRow, Debug, Serialize, Deserialize, PartialEq)] +pub struct RoomMember { + pub first_name: Option, + pub last_name: Option, + pub alias: Option, + pub username: String, + pub created_at: DateTime, + pub is_owner: bool, +} + #[derive(sqlx::FromRow)] pub struct Admin { pub user_id: Uuid, @@ -389,6 +498,13 @@ pub struct NewRoom { pub is_discoverable: bool, } +#[derive(sqlx::FromRow, Debug, Serialize, Deserialize, PartialEq)] +pub struct DiscoverableRoom { + pub room_name: String, + pub is_public: bool, + pub member_count: i64, +} + #[derive(sqlx::FromRow)] pub struct Room { pub room_name: String, diff --git a/src/room.rs b/src/room.rs index 4c748dc..24b734e 100644 --- a/src/room.rs +++ b/src/room.rs @@ -5,7 +5,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::hub::Hub; -use crate::model::{self, JoinRequestInfo, PublicUser, Room}; +use crate::model::{self, DiscoverableRoom, JoinRequestInfo, Room, RoomMember}; pub enum RoomRequest { NewRoomRequest { @@ -47,10 +47,23 @@ pub enum RoomRequest { room_name: String, tx: oneshot::Sender, }, + // Owner/admin removes another user's membership. + RemoveRoomMember { + source_user_id: Uuid, + room_name: String, + member_username: String, + tx: oneshot::Sender, + }, GetMyJoinRequests { source_user_id: Uuid, tx: oneshot::Sender, }, + // The caller withdraws their own pending join request. + CancelJoinRequest { + user_id: Uuid, + room_name: String, + tx: oneshot::Sender, + }, GetIncomingJoinRequests { source_user_id: Uuid, tx: oneshot::Sender, @@ -87,6 +100,14 @@ pub enum RoomRequest { room_name: String, tx: oneshot::Sender, }, + ListDiscoverableRooms { + tx: oneshot::Sender, + }, + // Admin only: every room, including private non-discoverable ones. + ListAllRooms { + source_user_id: Uuid, + tx: oneshot::Sender, + }, } pub enum RoomResponse { @@ -99,7 +120,7 @@ pub enum RoomResponse { is_discoverable: bool, }, RoomMembership { - members: Vec, + members: Vec, }, MyJoinRequests { rooms: Vec, @@ -110,6 +131,12 @@ pub enum RoomResponse { MyInvites { rooms: Vec, }, + DiscoverableRooms { + rooms: Vec, + }, + AllRooms { + rooms: Vec, + }, NoRoomExists, JoinRequested, Success, @@ -270,6 +297,31 @@ impl RoomHandle { rx.await.unwrap_or(RoomResponse::Failed) } + pub async fn remove_room_member( + &self, + source_user_id: Uuid, + room_name: String, + member_username: String, + ) -> RoomResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(RoomRequest::RemoveRoomMember { + source_user_id, + room_name, + member_username, + tx, + }) + .await + .is_err() + { + return RoomResponse::Failed; + } + + rx.await.unwrap_or(RoomResponse::Failed) + } + pub async fn get_my_join_requests(&self, source_user_id: Uuid) -> RoomResponse { let (tx, rx) = oneshot::channel(); @@ -285,6 +337,25 @@ impl RoomHandle { rx.await.unwrap_or(RoomResponse::Failed) } + pub async fn cancel_join_request(&self, user_id: Uuid, room_name: String) -> RoomResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(RoomRequest::CancelJoinRequest { + user_id, + room_name, + tx, + }) + .await + .is_err() + { + return RoomResponse::Failed; + } + + rx.await.unwrap_or(RoomResponse::Failed) + } + pub async fn get_incoming_join_requests(&self, source_user_id: Uuid) -> RoomResponse { let (tx, rx) = oneshot::channel(); @@ -427,6 +498,36 @@ impl RoomHandle { rx.await.unwrap_or(RoomResponse::Failed) } + + pub async fn list_discoverable_rooms(&self) -> RoomResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(RoomRequest::ListDiscoverableRooms { tx }) + .await + .is_err() + { + return RoomResponse::Failed; + } + + rx.await.unwrap_or(RoomResponse::Failed) + } + + pub async fn list_all_rooms(&self, source_user_id: Uuid) -> RoomResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(RoomRequest::ListAllRooms { source_user_id, tx }) + .await + .is_err() + { + return RoomResponse::Failed; + } + + rx.await.unwrap_or(RoomResponse::Failed) + } } pub async fn spawn(shutdown: CancellationToken, pool: PgPool, hub: Hub) -> RoomHandle { @@ -868,8 +969,8 @@ async fn handle_request( } } - let users = match sqlx::query_as::<_, PublicUser>( - "SELECT u.first_name, u.last_name, u.alias, u.username, u.created_at + let users = match sqlx::query_as::<_, RoomMember>( + "SELECT u.first_name, u.last_name, u.alias, u.username, u.created_at, m.is_owner FROM memberships m JOIN users u ON u.user_id = m.user_id WHERE m.room_id = $1 @@ -1029,6 +1130,142 @@ async fn handle_request( } } + RoomRequest::RemoveRoomMember { + source_user_id, + room_name, + member_username, + tx, + } => { + let mut db: sqlx::Transaction<'_, sqlx::Postgres> = match pool.begin().await { + Ok(db) => db, + Err(e) => { + tracing::error!(error = %e, "remove_room_member: begin transaction failed"); + return (RoomResponse::Failed, tx); + } + }; + + let room_for_log = room_name.clone(); + let member_for_log = member_username.clone(); + + // Authorize: caller must own the room (or be an admin). Lock the room + // row so the membership delete sees a stable authorization decision. + let gate = match sqlx::query_as::<_, (Uuid, bool)>( + "SELECT + r.room_id, + (EXISTS (SELECT 1 FROM memberships m + WHERE m.room_id = r.room_id AND m.user_id = $1 AND m.is_owner) + OR EXISTS (SELECT 1 FROM admins a WHERE a.user_id = $1)) AS authorized + FROM rooms r + WHERE LOWER(r.room_name) = LOWER(trim_ws($2)) + FOR UPDATE OF r", + ) + .bind(source_user_id) + .bind(room_name) + .fetch_optional(&mut *db) + .await + { + Ok(gate) => gate, + Err(e) => { + tracing::error!(error = %e, "remove_room_member: room authorization lookup failed"); + return (RoomResponse::Failed, tx); + } + }; + + // "No such room" and "not authorized" are reported identically so a + // non-owner can't probe a private room's existence. + let Some((room_id, authorized)) = gate else { + tracing::debug!(room = %room_for_log, "remove_room_member: no such room"); + return (RoomResponse::Failed, tx); + }; + if !authorized { + tracing::warn!(target: crate::logging::AUDIT, actor = %source_user_id, room = %room_for_log, "remove_room_member denied: caller is not an owner or admin"); + return (RoomResponse::Failed, tx); + } + + // Remove the target's membership, capturing their id so we can drop the + // room's live stream from their sessions. Zero rows -- not a member -- + // NoChange. + let removed_user_id = match sqlx::query_scalar::<_, Uuid>( + "DELETE FROM memberships + WHERE room_id = $1 + AND user_id = (SELECT user_id FROM users + WHERE LOWER(username) = LOWER(trim_ws($2))) + RETURNING user_id", + ) + .bind(room_id) + .bind(member_username) + .fetch_optional(&mut *db) + .await + { + Ok(id) => id, + Err(e) => { + tracing::error!(error = %e, %room_id, "remove_room_member: membership delete failed"); + return (RoomResponse::Failed, tx); + } + }; + + let Some(removed_user_id) = removed_user_id else { + return (RoomResponse::NoChange, tx); + }; + + match db.commit().await { + Ok(_) => { + tracing::info!(target: crate::logging::AUDIT, actor = %source_user_id, room = %room_for_log, target = %member_for_log, %room_id, "room member removed"); + // Cross-session: the removed user isn't the caller. Drop the + // room's live stream from any of their open sessions now, so + // they stop receiving its messages immediately instead of at + // their next reconnect. Their JIT membership checks already block + // posting/reading/downloading. + hub.unsubscribe_user_from_room(removed_user_id, room_id); + (RoomResponse::Success, tx) + } + Err(e) => { + tracing::error!(error = %e, %room_id, "remove_room_member: commit failed"); + (RoomResponse::Failed, tx) + } + } + } + + RoomRequest::CancelJoinRequest { + user_id, + room_name, + tx, + } => { + let mut db = match pool.acquire().await { + Ok(db) => db, + Err(e) => { + tracing::error!(error = %e, "cancel_join_request: acquire connection failed"); + return (RoomResponse::Failed, tx); + } + }; + + // Withdraw the caller's own pending request, resolving the room by name + // inline. A non-existent room or no pending request both delete zero + // rows -> NoChange. No authorization needed: a user owns their request. + let result = match sqlx::query( + "DELETE FROM room_join_requests + WHERE user_id = $1 + AND room_id = (SELECT room_id FROM rooms + WHERE LOWER(room_name) = LOWER(trim_ws($2)))", + ) + .bind(user_id) + .bind(room_name) + .execute(&mut *db) + .await + { + Ok(res) => res, + Err(e) => { + tracing::error!(error = %e, "cancel_join_request: request delete failed"); + return (RoomResponse::Failed, tx); + } + }; + + match result.rows_affected() { + 0 => (RoomResponse::NoChange, tx), + _ => (RoomResponse::Success, tx), + } + } + RoomRequest::GetMyJoinRequests { source_user_id, tx } => { let mut db = match pool.acquire().await { Ok(db) => db, @@ -1527,6 +1764,91 @@ async fn handle_request( _ => unreachable!(), } } + + RoomRequest::ListDiscoverableRooms { tx } => { + let mut db = match pool.acquire().await { + Ok(db) => db, + Err(e) => { + tracing::error!(error = %e, "list_discoverable_rooms: acquire connection failed"); + return (RoomResponse::Failed, tx); + } + }; + + // All rooms that are public or discoverable, with a live member count. + // Private non-discoverable rooms are excluded -- their existence must + // not be leaked to non-members. + let rooms = match sqlx::query_as::<_, DiscoverableRoom>( + "SELECT r.room_name, r.is_public, + COUNT(m.user_id) AS member_count + FROM rooms r + LEFT JOIN memberships m ON m.room_id = r.room_id + WHERE r.is_public OR r.is_discoverable + GROUP BY r.room_id + ORDER BY member_count DESC, r.room_name", + ) + .fetch_all(&mut *db) + .await + { + Ok(rooms) => rooms, + Err(e) => { + tracing::error!(error = %e, "list_discoverable_rooms: query failed"); + return (RoomResponse::Failed, tx); + } + }; + + (RoomResponse::DiscoverableRooms { rooms }, tx) + } + + RoomRequest::ListAllRooms { source_user_id, tx } => { + let mut db = match pool.acquire().await { + Ok(db) => db, + Err(e) => { + tracing::error!(error = %e, "list_all_rooms: acquire connection failed"); + return (RoomResponse::Failed, tx); + } + }; + + // Admin only: listing every room (private ones included) is a + // moderation capability. A non-admin caller is rejected outright. + let is_admin: bool = match sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM admins a WHERE a.user_id = $1)", + ) + .bind(source_user_id) + .fetch_one(&mut *db) + .await + { + Ok(is_admin) => is_admin, + Err(e) => { + tracing::error!(error = %e, "list_all_rooms: admin check failed"); + return (RoomResponse::Failed, tx); + } + }; + if !is_admin { + tracing::warn!(target: crate::logging::AUDIT, actor = %source_user_id, "list_all_rooms denied: caller is not an admin"); + return (RoomResponse::Failed, tx); + } + + // Every room, with a live member count -- no visibility filter. + let rooms = match sqlx::query_as::<_, DiscoverableRoom>( + "SELECT r.room_name, r.is_public, + COUNT(m.user_id) AS member_count + FROM rooms r + LEFT JOIN memberships m ON m.room_id = r.room_id + GROUP BY r.room_id + ORDER BY member_count DESC, r.room_name", + ) + .fetch_all(&mut *db) + .await + { + Ok(rooms) => rooms, + Err(e) => { + tracing::error!(error = %e, "list_all_rooms: query failed"); + return (RoomResponse::Failed, tx); + } + }; + + (RoomResponse::AllRooms { rooms }, tx) + } } } diff --git a/src/server.rs b/src/server.rs index b6c7cfe..a9bfb46 100644 --- a/src/server.rs +++ b/src/server.rs @@ -206,15 +206,16 @@ async fn spawn_receiver_task( &mut receiver, &auth_handle, &handles.user_handle, + &user_tx, open_signups, ) .await; match server_event { - ServerEvent::AuthOk => { + ServerEvent::AuthOk { is_admin } => { tracing::Span::current().record("user_id", tracing::field::display(user_id)); tracing::debug!("session authenticated"); - let _ = user_tx.send(ServerEvent::AuthOk).await; + let _ = user_tx.send(ServerEvent::AuthOk { is_admin }).await; } // Anything but AuthOk results in shutdown ServerEvent::NoAuth => { @@ -329,6 +330,7 @@ async fn spawn_receiver_task( &write_semaphore, &user_tx, user_id, + &hub, &shutdown, ) .await; @@ -549,10 +551,30 @@ async fn process_message( } ClientCommand::SendMessage { - room_id, + room_name, content, attachments, } => { + // Resolve the caller-supplied room name to a UUID for the + // internal message actor (which keys everything by room_id). + let room_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT room_id FROM rooms WHERE LOWER(room_name) = LOWER(trim_ws($1))", + ) + .bind(&room_name) + .fetch_optional(pool) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + let _ = user_tx.send(ServerEvent::Failed).await; + return ControlFlow::Continue(()); + } + Err(e) => { + tracing::error!(error = %e, "send_message: room name lookup failed"); + let _ = user_tx.send(ServerEvent::Failed).await; + return ControlFlow::Continue(()); + } + }; // The sender is the authenticated session user, never taken from // the client. Membership auth happens inside the actor's // transaction. On success the client gets the new message_id plus @@ -609,6 +631,13 @@ async fn process_message( .await; } + ClientCommand::GetSignupStatus => { + // Public server setting; also answerable in the prelude (pre-auth). + let _ = user_tx + .send(ServerEvent::SignupStatus { open_signups }) + .await; + } + ClientCommand::AddReaction { message_id, emoji } => { // The reactor is the authenticated session user, never client- // supplied. Membership auth happens inside the actor; an @@ -644,6 +673,25 @@ async fn process_message( } } + ClientCommand::DeleteMessage { message_id } => { + // The deleter is the authenticated session user; sender-or-admin auth + // happens inside the actor, which also fans out the MessageRemoved on + // success. A forbidden or unknown message collapses to a generic Failed. + let response = handles + .message_handle + .delete_message(user_id, message_id) + .await; + match response { + MessageResponse::Success => { + let _ = user_tx.send(ServerEvent::Success).await; + } + _ => { + tracing::debug!("delete_message: request failed"); + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } + ClientCommand::GetMessages { room_name, before, @@ -798,6 +846,28 @@ async fn process_message( } } + ClientCommand::GetUsers { + starts_with, + after, + limit, + } => { + // Open to any authenticated user; the caller's admin status (which + // gates the per-entry is_admin flag) is resolved inside the actor. + match handles + .user_handle + .get_users(user_id, starts_with, after, limit) + .await + { + UserResponse::Users { users, has_more } => { + let _ = user_tx.send(ServerEvent::Users { users, has_more }).await; + } + _ => { + tracing::debug!("get_users: request failed"); + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } + ClientCommand::DeleteUser { target_username } => { match handles .user_handle @@ -1038,6 +1108,27 @@ async fn process_message( } } + ClientCommand::RemoveRoomMember { + room_name, + member_username, + } => { + match handles + .room_handle + .remove_room_member(user_id, room_name, member_username) + .await + { + crate::room::RoomResponse::Success => { + let _ = user_tx.send(ServerEvent::Success).await; + } + crate::room::RoomResponse::NoChange => { + let _ = user_tx.send(ServerEvent::NoChange).await; + } + _ => { + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } + ClientCommand::GetMyJoinRequests => { match handles.room_handle.get_my_join_requests(user_id).await { crate::room::RoomResponse::MyJoinRequests { rooms } => { @@ -1049,6 +1140,24 @@ async fn process_message( } } + ClientCommand::CancelJoinRequest { room_name } => { + match handles + .room_handle + .cancel_join_request(user_id, room_name) + .await + { + crate::room::RoomResponse::Success => { + let _ = user_tx.send(ServerEvent::Success).await; + } + crate::room::RoomResponse::NoChange => { + let _ = user_tx.send(ServerEvent::NoChange).await; + } + _ => { + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } + ClientCommand::GetIncomingJoinRequests => { match handles .room_handle @@ -1173,6 +1282,28 @@ async fn process_message( } } } + + ClientCommand::ListDiscoverableRooms => { + match handles.room_handle.list_discoverable_rooms().await { + crate::room::RoomResponse::DiscoverableRooms { rooms } => { + let _ = user_tx.send(ServerEvent::DiscoverableRooms { rooms }).await; + } + _ => { + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } + + ClientCommand::ListAllRooms => { + match handles.room_handle.list_all_rooms(user_id).await { + crate::room::RoomResponse::AllRooms { rooms } => { + let _ = user_tx.send(ServerEvent::AllRooms { rooms }).await; + } + _ => { + let _ = user_tx.send(ServerEvent::Failed).await; + } + } + } }, Err(e) => { // Client-caused: malformed/oversized/incomplete input. Not a server @@ -1213,6 +1344,7 @@ async fn process_message( // // Frame layout: // [attachment_id: 16B][seq: u32 big-endian: 4B][payload...] +#[allow(clippy::too_many_arguments)] async fn process_binary( data: Vec, attachments: &mut HashMap, @@ -1220,6 +1352,7 @@ async fn process_binary( write_semaphore: &Arc, user_tx: &tokio::sync::mpsc::Sender, user_id: Uuid, + hub: &Hub, shutdown: &CancellationToken, ) { const HEADER_LEN: usize = attachment::CHUNK_HEADER_LEN; @@ -1281,8 +1414,8 @@ async fn process_binary( // Spawn-on-first-chunk / resume: confirm the caller is the sender of the // attachment's message and fetch the metadata needed to detect completion. One // ownership check per attachment, not per chunk. - let meta = sqlx::query_as::<_, (i32, i64, Vec, bool)>( - "SELECT a.chunk_count, a.size_bytes, a.content_sha256, a.is_complete + let meta = sqlx::query_as::<_, (i32, i64, Vec, String, bool)>( + "SELECT a.chunk_count, a.size_bytes, a.content_sha256, a.content_type, a.is_complete FROM message_attachments a JOIN messages m ON m.message_id = a.message_id WHERE a.attachment_id = $1 AND m.sender_id = $2", @@ -1292,7 +1425,7 @@ async fn process_binary( .fetch_optional(pool) .await; - let (chunk_count, size_bytes, content_sha256, is_complete) = match meta { + let (chunk_count, size_bytes, content_sha256, content_type, is_complete) = match meta { Ok(Some(row)) => row, Ok(None) => { let _ = user_tx @@ -1322,9 +1455,11 @@ async fn process_binary( chunk_count, size_bytes, content_sha256, + content_type, pool.clone(), write_semaphore.clone(), user_tx.clone(), + hub.clone(), shutdown.clone(), ); @@ -1362,76 +1497,110 @@ async fn new_user( .await } +// Largest number of pre-auth frames a client may send before it must Auth/NewUser. +// Only GetSignupStatus is non-terminal, so this caps a flood of signup-status +// queries on an unauthenticated socket, where the per-session limiter isn't active. +const MAX_PRELUDE_FRAMES: u32 = 16; + async fn prelude( receiver: &mut SplitStream, auth_handle: &AuthHandle, user_handle: &UserHandle, + user_tx: &mpsc::Sender, open_signups: bool, ) -> (Uuid, ServerEvent) { - tokio::select! { - maybe_auth = receiver.next() => { - match maybe_auth { - Some(Ok(Message::Text(t))) => { - match serde_json::from_str::(&t) { - Ok(ClientCommand::Auth{username, password}) => { - // Clone the username for audit logging; authenticate consumes it. - match auth_handle.authenticate(username.clone(), password).await { - AuthResult::Ok { user_id } => { - tracing::info!(target: crate::logging::AUDIT, %user_id, username = %username, "login succeeded"); - (user_id, ServerEvent::AuthOk) - } - AuthResult::Failed => { - tracing::warn!(target: crate::logging::AUDIT, username = %username, "login failed"); - (Uuid::nil(), ServerEvent::NoAuth) - } - // Server-side fault, not a credential rejection: already - // logged at error! in the auth actor, so don't audit it - // as a failed login. - AuthResult::Error => (Uuid::nil(), ServerEvent::NoAuth), + // The prelude loops only for a GetSignupStatus query (answered, then it keeps + // waiting). Auth / NewUser / any other frame returns a terminal outcome and ends + // it, exactly as before — so a first Auth/NewUser frame behaves unchanged. + let mut frames: u32 = 0; + loop { + frames += 1; + if frames > MAX_PRELUDE_FRAMES { + tracing::debug!("prelude frame cap exceeded without auth"); + return (Uuid::nil(), ServerEvent::NoAuth); + } + + let maybe_auth = receiver.next().await; + return match maybe_auth { + Some(Ok(Message::Text(t))) => { + match serde_json::from_str::(&t) { + // Pre-auth query: answer and keep waiting for Auth/NewUser. + Ok(ClientCommand::GetSignupStatus) => { + let _ = user_tx + .send(ServerEvent::SignupStatus { open_signups }) + .await; + continue; + } + Ok(ClientCommand::Auth { username, password }) => { + // Clone the username for audit logging; authenticate consumes it. + match auth_handle.authenticate(username.clone(), password).await { + AuthResult::Ok { user_id } => { + tracing::info!(target: crate::logging::AUDIT, %user_id, username = %username, "login succeeded"); + let is_admin = user_handle.is_admin(user_id).await; + (user_id, ServerEvent::AuthOk { is_admin }) } - }, - // Unauthenticated user creation is only allowed when open - // signups are enabled in the config; otherwise reject it. - Ok(ClientCommand::NewUser { username, .. }) if !open_signups => { - tracing::warn!(target: crate::logging::AUDIT, username = %username, "signup rejected: open signups disabled"); - (Uuid::nil(), ServerEvent::NoAuth) + AuthResult::Failed => { + tracing::warn!(target: crate::logging::AUDIT, username = %username, "login failed"); + (Uuid::nil(), ServerEvent::NoAuth) + } + // Server-side fault, not a credential rejection: already + // logged at error! in the auth actor, so don't audit it + // as a failed login. + AuthResult::Error => (Uuid::nil(), ServerEvent::NoAuth), } - Ok(ClientCommand::NewUser { - username, + } + // Unauthenticated user creation is only allowed when open + // signups are enabled in the config; otherwise reject it. + Ok(ClientCommand::NewUser { username, .. }) if !open_signups => { + tracing::warn!(target: crate::logging::AUDIT, username = %username, "signup rejected: open signups disabled"); + (Uuid::nil(), ServerEvent::NoAuth) + } + Ok(ClientCommand::NewUser { + username, + password, + first_name, + last_name, + alias, + }) => { + // Clone the username for audit logging; new_user consumes it. + match new_user( + user_handle, + username.clone(), password, first_name, last_name, - alias - }) => { - // Clone the username for audit logging; new_user consumes it. - match new_user(user_handle, username.clone(), password, first_name, last_name, alias).await { - // Account-creation audit happens in the user actor (the - // single point both signup and authenticated paths hit). - UserResponse::UserCreated { user_id } => (user_id, ServerEvent::UserCreated), - UserResponse::Failed => { - // Detailed cause is logged in the user actor; this is just the prelude outcome. - tracing::debug!(username = %username, "open-signup user creation failed"); - (Uuid::nil(), ServerEvent::Failed) - } - _ => { - tracing::debug!(username = %username, "open-signup user creation returned unexpected response"); - (Uuid::nil(), ServerEvent::Failed) - } + alias, + ) + .await + { + // Account-creation audit happens in the user actor (the + // single point both signup and authenticated paths hit). + UserResponse::UserCreated { user_id } => { + (user_id, ServerEvent::UserCreated) + } + UserResponse::Failed => { + // Detailed cause is logged in the user actor; this is just the prelude outcome. + tracing::debug!(username = %username, "open-signup user creation failed"); + (Uuid::nil(), ServerEvent::Failed) + } + _ => { + tracing::debug!(username = %username, "open-signup user creation returned unexpected response"); + (Uuid::nil(), ServerEvent::Failed) } - }, - Err(e) => { - tracing::debug!(error = %e, "undecodable command during prelude"); - (Uuid::nil(), ServerEvent::NoAuth) } - _ => (Uuid::nil(), ServerEvent::NoAuth) } + Err(e) => { + tracing::debug!(error = %e, "undecodable command during prelude"); + (Uuid::nil(), ServerEvent::NoAuth) + } + _ => (Uuid::nil(), ServerEvent::NoAuth), } - Some(Err(e)) => { - tracing::debug!(error = %e, "websocket error during prelude"); - (Uuid::nil(), ServerEvent::NoAuth) - } - _ => (Uuid::nil(), ServerEvent::NoAuth) } - } + Some(Err(e)) => { + tracing::debug!(error = %e, "websocket error during prelude"); + (Uuid::nil(), ServerEvent::NoAuth) + } + _ => (Uuid::nil(), ServerEvent::NoAuth), + }; } } diff --git a/src/user.rs b/src/user.rs index 3760fd8..4dabfad 100644 --- a/src/user.rs +++ b/src/user.rs @@ -12,6 +12,11 @@ use uuid::Uuid; use crate::model::{self, Admin, EditUser, NewCredential, NewUser, Password, User}; +// Directory page size when the client doesn't ask, and the hard cap when it does +// -- bounds one GetUsers response so a large user base can't be pulled at once. +const DEFAULT_USER_PAGE: i64 = 50; +const MAX_USER_PAGE: i64 = 100; + pub enum UserRequest { NewUserRequest { user: model::NewUser, @@ -26,6 +31,13 @@ pub enum UserRequest { username: String, tx: oneshot::Sender, }, + GetUsers { + source_user_id: Uuid, + starts_with: Option, + after: Option, + limit: Option, + tx: oneshot::Sender, + }, IsAdminRequest { user_id: Uuid, tx: oneshot::Sender, @@ -70,10 +82,23 @@ pub enum UserRequest { #[derive(Debug)] pub enum UserResponse { - UserCreated { user_id: Uuid }, - UserInfo { user_info: User }, - IsAdmin { is_admin: bool }, - UserDeleted { is_self: bool }, + UserCreated { + user_id: Uuid, + }, + UserInfo { + user_info: User, + }, + IsAdmin { + is_admin: bool, + }, + UserDeleted { + is_self: bool, + }, + // One page of the user directory, plus whether another page follows. + Users { + users: Vec, + has_more: bool, + }, NoChange, NoUserExists, Success, @@ -193,6 +218,35 @@ impl UserHandle { rx.await.unwrap_or(UserResponse::Failed) } + // Page the user directory on behalf of `source_user_id`. The caller's admin + // status (which gates the per-entry `is_admin` flag) is resolved in the actor. + pub async fn get_users( + &self, + source_user_id: Uuid, + starts_with: Option, + after: Option, + limit: Option, + ) -> UserResponse { + let (tx, rx) = oneshot::channel(); + + if self + .sender + .send(UserRequest::GetUsers { + source_user_id, + starts_with, + after, + limit, + tx, + }) + .await + .is_err() + { + return UserResponse::Failed; + } + + rx.await.unwrap_or(UserResponse::Failed) + } + pub async fn update_password( &self, source_user_id: Uuid, @@ -454,6 +508,30 @@ async fn handle_request( } } + UserRequest::GetUsers { + source_user_id, + starts_with, + after, + limit, + tx, + } => { + let mut db = match pool.acquire().await { + Ok(db) => db, + Err(e) => { + tracing::error!(error = %e, "get_users: acquire connection failed"); + return (UserResponse::Failed, tx); + } + }; + + match get_users(&mut db, source_user_id, starts_with, after, limit).await { + Ok(res) => (res, tx), + Err(e) => { + tracing::error!(error = %e, "get_users: query failed"); + (UserResponse::Failed, tx) + } + } + } + UserRequest::DeleteUserRequest { target_username, source_user_id, @@ -1004,6 +1082,89 @@ async fn get_user_by_username( } } +// One row of a directory page as it comes back from the DB. `is_admin` is always +// computed here; whether it's *revealed* to the caller is decided in `get_users`. +#[derive(sqlx::FromRow)] +struct DirectoryRow { + first_name: Option, + last_name: Option, + alias: Option, + username: String, + created_at: chrono::DateTime, + is_admin: bool, +} + +// Escape the LIKE metacharacters (`\`, `%`, `_`) in a user-supplied prefix so they +// match literally rather than as wildcards. Backslash is escaped first so the +// escapes we add aren't themselves re-escaped. Pairs with `ESCAPE '\'` in the query. +fn escape_like(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +// Page the user directory, ordered by username (case-insensitive), with an +// optional prefix filter and a keyset cursor. Fetches one extra row to report +// `has_more` without a second count query. The per-entry `is_admin` flag is only +// populated when the caller is themselves an admin; for a regular caller it is +// None (and omitted on the wire), so admin status isn't leaked to non-admins. +async fn get_users( + db: &mut PgConnection, + source_user_id: Uuid, + starts_with: Option, + after: Option, + limit: Option, +) -> Result { + // Clamp the page size: default when unset, hard cap, floor 1 so a request + // always makes progress. Fetch one extra to detect a following page. + let limit = limit + .map(|l| (l as i64).clamp(1, MAX_USER_PAGE)) + .unwrap_or(DEFAULT_USER_PAGE); + let fetch = limit + 1; + + // Normalize the optional prefix: trim, drop if empty, then escape LIKE + // metacharacters so a literal `%`/`_` in the prefix isn't treated as a wildcard. + let prefix = starts_with + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .map(|s| escape_like(&s)); + + // Reveal admin status only to an admin caller (the admin-pane use case). + let caller_is_admin = is_admin(&mut *db, source_user_id).await?; + + let rows = sqlx::query_as::<_, DirectoryRow>( + "SELECT u.first_name, u.last_name, u.alias, u.username, u.created_at, + EXISTS (SELECT 1 FROM admins a WHERE a.user_id = u.user_id) AS is_admin + FROM users u + WHERE ($1::text IS NULL OR LOWER(u.username) LIKE LOWER($1) || '%' ESCAPE '\\') + AND ($2::text IS NULL OR LOWER(u.username) > LOWER($2)) + ORDER BY LOWER(u.username) ASC + LIMIT $3", + ) + .bind(prefix.as_deref()) + .bind(after.as_deref()) + .bind(fetch) + .fetch_all(&mut *db) + .await?; + + let has_more = rows.len() as i64 > limit; + let users = rows + .into_iter() + .take(limit as usize) + .map(|r| model::UserDirectoryEntry { + first_name: r.first_name, + last_name: r.last_name, + alias: r.alias, + username: r.username, + created_at: r.created_at, + is_admin: caller_is_admin.then_some(r.is_admin), + }) + .collect(); + + Ok(UserResponse::Users { users, has_more }) +} + async fn create_admin( db: &mut PgConnection, username: &str, diff --git a/tests/cancel_join_request.rs b/tests/cancel_join_request.rs new file mode 100644 index 0000000..81e02fc --- /dev/null +++ b/tests/cancel_join_request.rs @@ -0,0 +1,99 @@ +mod common; + +use common::*; +use sqlx::PgPool; + +fn cancel_cmd(room: &str) -> ClientCommand { + ClientCommand::CancelJoinRequest { + room_name: room.to_owned(), + } +} + +// Seed a pending join request directly, bypassing the join protocol. +async fn seed_join_request(pool: &PgPool, room: &str, username: &str) { + sqlx::query( + "INSERT INTO room_join_requests (room_id, user_id) + SELECT r.room_id, u.user_id FROM rooms r, users u + WHERE r.room_name = $1 AND u.username = $2", + ) + .bind(room) + .bind(username) + .execute(pool) + .await + .unwrap(); +} + +async fn request_count(pool: &PgPool, room: &str, username: &str) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM room_join_requests jr + JOIN rooms r ON r.room_id = jr.room_id + JOIN users u ON u.user_id = jr.user_id + WHERE r.room_name = $1 AND u.username = $2", + ) + .bind(room) + .bind(username) + .fetch_one(pool) + .await + .unwrap() +} + +// The requester withdraws their own pending request: it's gone. +#[sqlx::test] +async fn requester_cancels_own_request(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", false, true).await; + seed_join_request(&pool, "club", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &cancel_cmd("club")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Success); + assert_eq!(request_count(&pool, "club", "bob").await, 0); + // Cancelling a request never makes the caller a member. + assert!(!is_member(&pool, "club", "bob").await); + + close_socket(&mut ws).await; +} + +// Cancelling with no pending request is a no-op. +#[sqlx::test] +async fn cancel_without_request_no_change(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", false, true).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &cancel_cmd("club")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::NoChange); + + close_socket(&mut ws).await; +} + +// A cancel only withdraws the caller's own request, not another user's. +#[sqlx::test] +async fn cancel_only_affects_own_request(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_user(&pool, "carol", "carolpw").await; + seed_room(&pool, "club", "alice", false, true).await; + seed_join_request(&pool, "club", "bob").await; + seed_join_request(&pool, "club", "carol").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &cancel_cmd("club")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Success); + assert_eq!(request_count(&pool, "club", "bob").await, 0); + // carol's request is untouched. + assert_eq!(request_count(&pool, "club", "carol").await, 1); + + close_socket(&mut ws).await; +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 94c821b..82dd1a6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -225,7 +225,10 @@ pub async fn authenticate(ws: &mut Ws, username: &str, password: &str) { }, ) .await; - assert_eq!(next_event(ws).await, ServerEvent::AuthOk); + assert!( + matches!(next_event(ws).await, ServerEvent::AuthOk { .. }), + "expected AuthOk", + ); } // Gracefully close the socket; reaching the Close frame proves the diff --git a/tests/delete_message.rs b/tests/delete_message.rs new file mode 100644 index 0000000..4125855 --- /dev/null +++ b/tests/delete_message.rs @@ -0,0 +1,180 @@ +mod common; + +use common::*; +use sqlx::PgPool; +use uuid::Uuid; + +// Post a plain message to `room_name` and return its id. Skips the live echo so +// the MessageCreated ack is what we read. +async fn post(ws: &mut Ws, room_name: &str, content: &str) -> Uuid { + send_cmd( + ws, + &ClientCommand::SendMessage { + room_name: room_name.to_owned(), + content: content.to_owned(), + attachments: vec![], + }, + ) + .await; + match next_reply(ws).await { + ServerEvent::MessageCreated { message_id, .. } => message_id, + other => panic!("expected MessageCreated, got {other:?}"), + } +} + +async fn message_exists(pool: &PgPool, id: Uuid) -> bool { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages WHERE message_id = $1") + .bind(id) + .fetch_one(pool) + .await + .expect("count messages") + > 0 +} + +// Drive a DeleteMessage and read until the expected outcome. The deleter is +// subscribed to the room, so a MessageRemoved broadcast can interleave with the +// Success/Failed reply in either order; collect past it. +async fn delete_and_expect_success(ws: &mut Ws, message_id: Uuid) { + send_cmd(ws, &ClientCommand::DeleteMessage { message_id }).await; + loop { + match next_reply(ws).await { + ServerEvent::Success => return, + ServerEvent::MessageRemoved { .. } => continue, + other => panic!("expected Success, got {other:?}"), + } + } +} + +// A sender can unsend their own message: the server acks Success and the row is +// deleted. +#[sqlx::test] +async fn sender_can_unsend_own_message(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "general", "alice", true, true).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + let message_id = post(&mut ws, "general", "hello").await; + assert!(message_exists(&pool, message_id).await); + + delete_and_expect_success(&mut ws, message_id).await; + assert!( + !message_exists(&pool, message_id).await, + "the message should be deleted" + ); + + close_socket(&mut ws).await; +} + +// An admin can remove anyone's message, even a room they don't belong to +// (moderation). Carol is an admin but not a member of `general`. +#[sqlx::test] +async fn admin_can_remove_any_message(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_admin(&pool, "carol", "carolpw").await; + seed_room(&pool, "general", "alice", true, true).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = create_socket(server.addr).await; + authenticate(&mut alice, "alice", "alicepw").await; + let message_id = post(&mut alice, "general", "hello").await; + + let mut carol = create_socket(server.addr).await; + authenticate(&mut carol, "carol", "carolpw").await; + delete_and_expect_success(&mut carol, message_id).await; + + assert!( + !message_exists(&pool, message_id).await, + "admin removal should delete the message" + ); + + // Alice, a subscribed member, sees the admin's removal live -- drain it (and + // her own post echo) before closing so the close handshake is clean. + loop { + match next_event(&mut alice).await { + ServerEvent::MessageRemoved { message_id: id, .. } => { + assert_eq!(id, message_id); + break; + } + ServerEvent::NewMessage { .. } | ServerEvent::Resync { .. } => continue, + other => panic!("expected MessageRemoved, got {other:?}"), + } + } + + close_socket(&mut alice).await; + close_socket(&mut carol).await; +} + +// A plain member who is neither the author nor an admin cannot delete someone +// else's message: the request fails and the message survives. +#[sqlx::test] +async fn non_author_non_admin_cannot_delete(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "general", "alice", true, true).await; + seed_membership(&pool, "general", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = create_socket(server.addr).await; + authenticate(&mut alice, "alice", "alicepw").await; + let message_id = post(&mut alice, "general", "hello").await; + + let mut bob = create_socket(server.addr).await; + authenticate(&mut bob, "bob", "bobpw").await; + send_cmd(&mut bob, &ClientCommand::DeleteMessage { message_id }).await; + assert_eq!(next_reply(&mut bob).await, ServerEvent::Failed); + + assert!( + message_exists(&pool, message_id).await, + "the message must survive a forbidden delete" + ); + + close_socket(&mut alice).await; + close_socket(&mut bob).await; +} + +// Deleting a message fans a MessageRemoved out to the room's other live members, +// so it disappears for everyone, not just the deleter. +#[sqlx::test] +async fn deletion_is_broadcast_to_room_members(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "general", "alice", true, true).await; + seed_membership(&pool, "general", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = create_socket(server.addr).await; + authenticate(&mut alice, "alice", "alicepw").await; + let mut bob = create_socket(server.addr).await; + authenticate(&mut bob, "bob", "bobpw").await; + + let message_id = post(&mut alice, "general", "hello").await; + // Bob sees the live post first. + match next_event(&mut bob).await { + ServerEvent::NewMessage { message, .. } => assert_eq!(message.message_id, message_id), + other => panic!("expected NewMessage, got {other:?}"), + } + + delete_and_expect_success(&mut alice, message_id).await; + + // Bob gets the removal live (skipping any other interleaved push). + loop { + match next_event(&mut bob).await { + ServerEvent::MessageRemoved { + room_name, + message_id: id, + } => { + assert_eq!(room_name, "general"); + assert_eq!(id, message_id); + break; + } + ServerEvent::NewMessage { .. } | ServerEvent::Resync { .. } => continue, + other => panic!("expected MessageRemoved, got {other:?}"), + } + } + + close_socket(&mut alice).await; + close_socket(&mut bob).await; +} diff --git a/tests/download_attachment.rs b/tests/download_attachment.rs index f8e0d0f..c152978 100644 --- a/tests/download_attachment.rs +++ b/tests/download_attachment.rs @@ -222,8 +222,12 @@ async fn upload_then_download_round_trips_bytes(pool: PgPool) { authenticate(&mut ws, "alice", "alicepw").await; // A non-trivial "file" split into uneven chunks: four full 1024B chunks and a - // short final one, so the last frame differs in length from the rest. - let original: Vec = (0..5000u32).map(|i| (i % 251) as u8).collect(); + // short final one, so the last frame differs in length from the rest. It opens + // with a real PNG signature so the server's content-type policy detects and + // accepts it as image/png (the filler bytes include NUL, so it can't pass as + // text); the rest is arbitrary, exercising byte-exact round-tripping. + let mut original: Vec = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + original.extend((0..4992u32).map(|i| (i % 251) as u8)); let parts: Vec<&[u8]> = original.chunks(1024).collect(); assert_eq!(parts.len(), 5); @@ -233,17 +237,14 @@ async fn upload_then_download_round_trips_bytes(pool: PgPool) { hasher.update(&original); let content_sha256 = hasher.finalize().to_vec(); - // Declare the attachment alongside the message and grab the id the chunks key - // against. - let rid = room_id(&pool, "general").await; send_cmd( &mut ws, &ClientCommand::SendMessage { - room_id: rid, + room_name: "general".to_owned(), content: "with file".to_owned(), attachments: vec![NewMessageAttachment { - filename: "roundtrip.bin".to_owned(), - content_type: "application/octet-stream".to_owned(), + filename: "roundtrip.png".to_owned(), + content_type: "image/png".to_owned(), size_bytes: original.len() as i64, chunk_count: parts.len() as i32, content_sha256, diff --git a/tests/fan_out.rs b/tests/fan_out.rs index cf4faa4..f9846e4 100644 --- a/tests/fan_out.rs +++ b/tests/fan_out.rs @@ -31,11 +31,11 @@ async fn connect(server: &TestServer, username: &str, password: &str) -> Ws { // Post a message and return its id, skipping the poster's own live echo to read // the MessageCreated ack. -async fn post(ws: &mut Ws, room_id: Uuid, content: &str) -> Uuid { +async fn post(ws: &mut Ws, room_name: &str, content: &str) -> Uuid { send_cmd( ws, &ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: content.to_owned(), attachments: vec![], }, @@ -72,12 +72,11 @@ async fn member_receives_another_members_message_live(pool: PgPool) { seed_room(&pool, "vault", "alice", false, false).await; seed_membership(&pool, "vault", "bob").await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; let mut bob = connect(&server, "bob", "bobpw").await; - let message_id = post(&mut alice, room, "hello room").await; + let message_id = post(&mut alice, "vault", "hello room").await; let (room_name, message) = recv_new_message(&mut bob).await; assert_eq!(room_name, "vault"); @@ -91,14 +90,13 @@ async fn sender_receives_its_own_echo(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; send_cmd( &mut alice, &ClientCommand::SendMessage { - room_id: room, + room_name: "vault".to_owned(), content: "echo me".to_owned(), attachments: vec![], }, @@ -130,13 +128,12 @@ async fn non_member_receives_nothing(pool: PgPool) { seed_user(&pool, "carl", "carlpw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; // carl is a valid, connected user but not a member of vault. let mut carl = connect(&server, "carl", "carlpw").await; - post(&mut alice, room, "members only").await; + post(&mut alice, "vault", "members only").await; // carl never subscribed to vault, so nothing is delivered to him. assert_no_event(&mut carl, Duration::from_millis(500)).await; @@ -148,13 +145,12 @@ async fn joining_subscribes_to_live_messages(pool: PgPool) { seed_user(&pool, "bob", "bobpw").await; seed_room(&pool, "vault", "alice", true, false).await; // public so bob can join let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; let mut bob = connect(&server, "bob", "bobpw").await; // Before joining, bob isn't subscribed. - post(&mut alice, room, "before join").await; + post(&mut alice, "vault", "before join").await; assert_no_event(&mut bob, Duration::from_millis(300)).await; // Joining subscribes him: subscribe_room runs before the Success reply, so once @@ -168,7 +164,7 @@ async fn joining_subscribes_to_live_messages(pool: PgPool) { .await; assert_eq!(next_reply(&mut bob).await, ServerEvent::Success); - let message_id = post(&mut alice, room, "after join").await; + let message_id = post(&mut alice, "vault", "after join").await; let (_, message) = recv_new_message(&mut bob).await; assert_eq!(message.message_id, message_id); assert_eq!(message.content, "after join"); @@ -181,7 +177,6 @@ async fn leaving_unsubscribes_from_live_messages(pool: PgPool) { seed_room(&pool, "vault", "alice", true, false).await; seed_membership(&pool, "vault", "bob").await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; let mut bob = connect(&server, "bob", "bobpw").await; @@ -199,7 +194,7 @@ async fn leaving_unsubscribes_from_live_messages(pool: PgPool) { // the post; give it a moment to drain so the StreamMap entry is really gone. sleep(Duration::from_millis(200)).await; - post(&mut alice, room, "after bob left").await; + post(&mut alice, "vault", "after bob left").await; assert_no_event(&mut bob, Duration::from_millis(500)).await; } @@ -210,7 +205,6 @@ async fn approval_subscribes_an_online_requester(pool: PgPool) { // Private but discoverable: bob can request to join, the owner approves. seed_room(&pool, "vault", "alice", false, true).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = connect(&server, "alice", "alicepw").await; let mut bob = connect(&server, "bob", "bobpw").await; @@ -238,8 +232,47 @@ async fn approval_subscribes_an_online_requester(pool: PgPool) { assert_eq!(next_reply(&mut alice).await, ServerEvent::Success); // bob gets live messages without reconnecting. - let message_id = post(&mut alice, room, "welcome aboard").await; + let message_id = post(&mut alice, "vault", "welcome aboard").await; let (_, message) = recv_new_message(&mut bob).await; assert_eq!(message.message_id, message_id); assert_eq!(message.content, "welcome aboard"); } + +// The mirror of the approval case: removing a member pushes a Subscription::Remove +// to that member's open session (cross-session, via the hub), so their live feed +// for the room is cut immediately -- a message posted after removal never arrives. +#[sqlx::test] +async fn removed_member_stops_receiving_live(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "vault", "alice", false, false).await; + seed_membership(&pool, "vault", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = connect(&server, "alice", "alicepw").await; + let mut bob = connect(&server, "bob", "bobpw").await; + + // Baseline: bob receives a live message while still a member. + let before = post(&mut alice, "vault", "before").await; + let (_, message) = recv_new_message(&mut bob).await; + assert_eq!(message.message_id, before); + + // Owner removes bob. + send_cmd( + &mut alice, + &ClientCommand::RemoveRoomMember { + room_name: "vault".to_owned(), + member_username: "bob".to_owned(), + }, + ) + .await; + assert_eq!(next_reply(&mut alice).await, ServerEvent::Success); + + // Let bob's sender task process the unsubscribe before the next post, so the + // broadcast can't race ahead of the stream removal. + sleep(Duration::from_millis(200)).await; + + // A message posted after removal must not reach bob's now-unsubscribed session. + post(&mut alice, "vault", "after").await; + assert_no_event(&mut bob, Duration::from_millis(400)).await; +} diff --git a/tests/get_messages.rs b/tests/get_messages.rs index fcd41a7..5db7688 100644 --- a/tests/get_messages.rs +++ b/tests/get_messages.rs @@ -13,11 +13,11 @@ async fn login(server: &TestServer, username: &str, password: &str) -> Ws { } // Post a message over the wire and return its id. -async fn post(ws: &mut Ws, room_id: Uuid, content: &str) -> Uuid { +async fn post(ws: &mut Ws, room_name: &str, content: &str) -> Uuid { send_cmd( ws, &ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: content.to_owned(), attachments: vec![], }, @@ -67,12 +67,11 @@ async fn returns_room_messages_newest_first(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut ws = login(&server, "alice", "alicepw").await; - post(&mut ws, room, "first").await; - post(&mut ws, room, "second").await; - post(&mut ws, room, "third").await; + post(&mut ws, "vault", "first").await; + post(&mut ws, "vault", "second").await; + post(&mut ws, "vault", "third").await; let page = history(&mut ws, "vault", None, None).await; assert_eq!(contents(&page), vec!["third", "second", "first"]); @@ -85,11 +84,10 @@ async fn paginates_backwards_with_before_cursor(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut ws = login(&server, "alice", "alicepw").await; for n in 1..=5 { - post(&mut ws, room, &format!("m{n}")).await; + post(&mut ws, "vault", &format!("m{n}")).await; } let page1 = history(&mut ws, "vault", None, Some(2)).await; @@ -114,11 +112,10 @@ async fn limit_is_clamped(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut ws = login(&server, "alice", "alicepw").await; for n in 1..=3 { - post(&mut ws, room, &format!("m{n}")).await; + post(&mut ws, "vault", &format!("m{n}")).await; } // limit 0 floors to 1 rather than returning nothing. @@ -131,13 +128,12 @@ async fn attachments_appear_in_history(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut ws = login(&server, "alice", "alicepw").await; send_cmd( &mut ws, &ClientCommand::SendMessage { - room_id: room, + room_name: "vault".to_owned(), content: "with file".to_owned(), attachments: vec![NewMessageAttachment { filename: "notes.txt".to_owned(), @@ -172,10 +168,9 @@ async fn reactions_are_summarized_with_caller_flag(pool: PgPool) { seed_room(&pool, "vault", "alice", false, false).await; seed_membership(&pool, "vault", "bob").await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; - let message_id = post(&mut alice, room, "react to me").await; + let message_id = post(&mut alice, "vault", "react to me").await; // alice 👍 ; bob 👍 and 🎉 let mut bob = login(&server, "bob", "bobpw").await; @@ -211,10 +206,9 @@ async fn non_member_is_denied_and_room_existence_hidden(pool: PgPool) { seed_user(&pool, "mallory", "mallorypw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; - post(&mut alice, room, "secret").await; + post(&mut alice, "vault", "secret").await; let mut mallory = login(&server, "mallory", "mallorypw").await; @@ -233,6 +227,25 @@ async fn non_member_is_denied_and_room_existence_hidden(pool: PgPool) { } } +// An admin can read history in a private room they're not a member of, so they +// can moderate any room's content. +#[sqlx::test] +async fn admin_reads_non_member_room(pool: PgPool) { + seed_admin(&pool, "admin", "adminpw").await; + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "vault", "alice", false, false).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = login(&server, "alice", "alicepw").await; + post(&mut alice, "vault", "members only").await; + post(&mut alice, "vault", "still here").await; + + // admin is not a member, but reads the full history anyway. + let mut admin = login(&server, "admin", "adminpw").await; + let page = history(&mut admin, "vault", None, None).await; + assert_eq!(contents(&page), vec!["still here", "members only"]); +} + #[sqlx::test] async fn deleted_sender_falls_back_to_snapshot(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; diff --git a/tests/get_room_membership.rs b/tests/get_room_membership.rs index 5f318a6..890e518 100644 --- a/tests/get_room_membership.rs +++ b/tests/get_room_membership.rs @@ -1,7 +1,7 @@ mod common; use common::*; -use relay::model::PublicUser; +use relay::model::RoomMember; use sqlx::PgPool; fn get_members_cmd(room: &str) -> ClientCommand { @@ -10,7 +10,7 @@ fn get_members_cmd(room: &str) -> ClientCommand { } } -fn usernames(members: &[PublicUser]) -> Vec<&str> { +fn usernames(members: &[RoomMember]) -> Vec<&str> { members.iter().map(|m| m.username.as_str()).collect() } @@ -36,6 +36,9 @@ async fn lists_members_owner_first(pool: PgPool) { ServerEvent::RoomMembers { members } => { // alice is the owner, so she sorts first. assert_eq!(usernames(&members), vec!["alice", "bob"]); + // Ownership is reflected per member. + assert!(members[0].is_owner, "alice should be an owner"); + assert!(!members[1].is_owner, "bob should not be an owner"); } other => panic!("expected RoomMembers, got {other:?}"), } diff --git a/tests/get_users.rs b/tests/get_users.rs new file mode 100644 index 0000000..58f19d4 --- /dev/null +++ b/tests/get_users.rs @@ -0,0 +1,158 @@ +mod common; + +use common::*; +use relay::model::UserDirectoryEntry; +use sqlx::PgPool; + +fn get_users(starts_with: Option<&str>, after: Option<&str>, limit: Option) -> ClientCommand { + ClientCommand::GetUsers { + starts_with: starts_with.map(str::to_owned), + after: after.map(str::to_owned), + limit, + } +} + +// Read one directory page (skipping any interleaved live pushes). +async fn page(ws: &mut Ws) -> (Vec, bool) { + match next_reply(ws).await { + ServerEvent::Users { users, has_more } => (users, has_more), + other => panic!("expected Users, got {other:?}"), + } +} + +fn usernames(users: &[UserDirectoryEntry]) -> Vec { + users.iter().map(|u| u.username.clone()).collect() +} + +// The directory is ordered by username and pages via the `after` keyset cursor, +// reporting `has_more` until the last page. +#[sqlx::test] +async fn lists_users_ordered_and_paged(pool: PgPool) { + for name in ["alice", "bob", "carol", "dave"] { + seed_user(&pool, name, "pw").await; + } + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "pw").await; + + // First page of two: ordered, more to come. + send_cmd(&mut ws, &get_users(None, None, Some(2))).await; + let (first, has_more) = page(&mut ws).await; + assert_eq!(usernames(&first), vec!["alice", "bob"]); + assert!(has_more); + + // Continue past the last username seen. + send_cmd(&mut ws, &get_users(None, Some("bob"), Some(2))).await; + let (second, has_more) = page(&mut ws).await; + assert_eq!(usernames(&second), vec!["carol", "dave"]); + assert!(!has_more, "the final page reports no more"); + + close_socket(&mut ws).await; +} + +// `starts_with` filters by a case-insensitive username prefix. +#[sqlx::test] +async fn starts_with_filters_by_prefix(pool: PgPool) { + for name in ["alan", "alice", "bob"] { + seed_user(&pool, name, "pw").await; + } + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "pw").await; + + // Mixed-case prefix still matches. + send_cmd(&mut ws, &get_users(Some("AL"), None, None)).await; + let (users, has_more) = page(&mut ws).await; + assert_eq!(usernames(&users), vec!["alan", "alice"]); + assert!(!has_more); + + close_socket(&mut ws).await; +} + +// A `_` or `%` in the prefix is matched literally, not as a LIKE wildcard. +#[sqlx::test] +async fn starts_with_escapes_like_metacharacters(pool: PgPool) { + for name in ["a_b", "axb", "acb"] { + seed_user(&pool, name, "pw").await; + } + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "a_b", "pw").await; + + // "a_" must match only the literal "a_b", not "axb"/"acb". + send_cmd(&mut ws, &get_users(Some("a_"), None, None)).await; + let (users, _) = page(&mut ws).await; + assert_eq!(usernames(&users), vec!["a_b"]); + + close_socket(&mut ws).await; +} + +// An admin caller sees each entry's `is_admin` flag. +#[sqlx::test] +async fn admin_sees_admin_flag(pool: PgPool) { + seed_admin(&pool, "boss", "pw").await; + seed_user(&pool, "alice", "pw").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "boss", "pw").await; + + send_cmd(&mut ws, &get_users(None, None, None)).await; + let (users, _) = page(&mut ws).await; + let alice = users.iter().find(|u| u.username == "alice").unwrap(); + let boss = users.iter().find(|u| u.username == "boss").unwrap(); + assert_eq!(alice.is_admin, Some(false)); + assert_eq!( + boss.is_admin, + Some(true), + "admin caller can identify other admins" + ); + + close_socket(&mut ws).await; +} + +// A regular caller never sees admin status: the flag is omitted (None) for every +// entry, including the admins themselves. +#[sqlx::test] +async fn regular_user_does_not_see_admin_flag(pool: PgPool) { + seed_admin(&pool, "boss", "pw").await; + seed_user(&pool, "alice", "pw").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "pw").await; + + send_cmd(&mut ws, &get_users(None, None, None)).await; + let (users, _) = page(&mut ws).await; + assert!( + users.iter().all(|u| u.is_admin.is_none()), + "non-admin callers must not see is_admin on any entry" + ); + + close_socket(&mut ws).await; +} + +// The page size is capped, and an unfiltered listing is available to any +// authenticated (non-admin) user. +#[sqlx::test] +async fn limit_is_clamped(pool: PgPool) { + for i in 0..5 { + seed_user(&pool, &format!("user{i}"), "pw").await; + } + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "user0", "pw").await; + + // Asking for more than the hard cap (100) still works; here we just confirm an + // oversized request returns everything we have (5 < cap) and reports no more. + send_cmd(&mut ws, &get_users(None, None, Some(10_000))).await; + let (users, has_more) = page(&mut ws).await; + assert_eq!(users.len(), 5); + assert!(!has_more); + + close_socket(&mut ws).await; +} diff --git a/tests/list_all_rooms.rs b/tests/list_all_rooms.rs new file mode 100644 index 0000000..45626af --- /dev/null +++ b/tests/list_all_rooms.rs @@ -0,0 +1,54 @@ +mod common; + +use common::*; +use sqlx::PgPool; + +fn list_all_cmd() -> ClientCommand { + ClientCommand::ListAllRooms +} + +// An admin sees every room, including private non-discoverable ones that the +// public discover listing hides. +#[sqlx::test] +async fn admin_lists_all_rooms_including_private(pool: PgPool) { + seed_admin(&pool, "admin", "adminpw").await; + seed_user(&pool, "alice", "alicepw").await; + // A public room, a discoverable private room, and a fully private one. + seed_room(&pool, "general", "alice", true, false).await; + seed_room(&pool, "staff", "alice", false, true).await; + seed_room(&pool, "vault", "alice", false, false).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "admin", "adminpw").await; + + send_cmd(&mut ws, &list_all_cmd()).await; + let names = match next_event(&mut ws).await { + ServerEvent::AllRooms { rooms } => { + rooms.into_iter().map(|r| r.room_name).collect::>() + } + other => panic!("expected AllRooms, got {other:?}"), + }; + // All three present, including the private non-discoverable "vault". + assert!(names.contains(&"general".to_owned())); + assert!(names.contains(&"staff".to_owned())); + assert!(names.contains(&"vault".to_owned())); + + close_socket(&mut ws).await; +} + +// A non-admin is rejected: listing every room is a moderation-only capability. +#[sqlx::test] +async fn non_admin_is_rejected(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "general", "alice", true, false).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_all_cmd()).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Failed); + + close_socket(&mut ws).await; +} diff --git a/tests/list_discoverable_rooms.rs b/tests/list_discoverable_rooms.rs new file mode 100644 index 0000000..5fe3200 --- /dev/null +++ b/tests/list_discoverable_rooms.rs @@ -0,0 +1,227 @@ +mod common; + +use common::*; +use relay::model::DiscoverableRoom; +use sqlx::PgPool; + +fn list_cmd() -> ClientCommand { + ClientCommand::ListDiscoverableRooms +} + +// ##### Visibility rules ##### + +// An empty server returns an empty list. +#[sqlx::test] +async fn empty_when_no_rooms(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { rooms: vec![] } + ); + + close_socket(&mut ws).await; +} + +// Public rooms appear in the listing. +#[sqlx::test] +async fn public_room_appears(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "general", "alice", true, false).await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { + rooms: vec![DiscoverableRoom { + room_name: "general".to_owned(), + is_public: true, + member_count: 1, + }] + } + ); + + close_socket(&mut ws).await; +} + +// Discoverable-but-private rooms appear in the listing with is_public = false. +#[sqlx::test] +async fn discoverable_private_room_appears(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "club", "alice", false, true).await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { + rooms: vec![DiscoverableRoom { + room_name: "club".to_owned(), + is_public: false, + member_count: 1, + }] + } + ); + + close_socket(&mut ws).await; +} + +// Private non-discoverable rooms are absent — existence must not be leaked. +#[sqlx::test] +async fn private_non_discoverable_room_hidden(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "secret", "alice", false, false).await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { rooms: vec![] } + ); + + close_socket(&mut ws).await; +} + +// Private non-discoverable rooms are absent even for their own members. +#[sqlx::test] +async fn private_non_discoverable_room_hidden_from_member(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "secret", "alice", false, false).await; + seed_membership(&pool, "secret", "bob").await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { rooms: vec![] } + ); + + close_socket(&mut ws).await; +} + +// ##### Member count ##### + +// member_count reflects actual membership, not just ownership. +#[sqlx::test] +async fn member_count_is_accurate(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_user(&pool, "carol", "carolpw").await; + seed_room(&pool, "general", "alice", true, true).await; + seed_membership(&pool, "general", "bob").await; + seed_membership(&pool, "general", "carol").await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_cmd()).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::DiscoverableRooms { + rooms: vec![DiscoverableRoom { + room_name: "general".to_owned(), + is_public: true, + member_count: 3, + }] + } + ); + + close_socket(&mut ws).await; +} + +// ##### Ordering ##### + +// Rooms are returned ordered by member_count descending, then name ascending. +#[sqlx::test] +async fn ordered_by_member_count_then_name(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_user(&pool, "carol", "carolpw").await; + // "popular" has 3 members; "alpha" and "zeta" have 1 each (tied, sorted by name). + seed_room(&pool, "popular", "alice", true, true).await; + seed_membership(&pool, "popular", "bob").await; + seed_membership(&pool, "popular", "carol").await; + seed_room(&pool, "zeta", "alice", true, true).await; + seed_room(&pool, "alpha", "alice", true, true).await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &list_cmd()).await; + let event = next_event(&mut ws).await; + let names: Vec<&str> = match &event { + ServerEvent::DiscoverableRooms { rooms } => { + rooms.iter().map(|r| r.room_name.as_str()).collect() + } + other => panic!("expected DiscoverableRooms, got {other:?}"), + }; + assert_eq!(names, vec!["popular", "alpha", "zeta"]); + + close_socket(&mut ws).await; +} + +// ##### Mixed room types ##### + +// A mix of public, discoverable-private, and hidden rooms: only the first two +// appear, and member counts are independent. +#[sqlx::test] +async fn mixed_visibility_rooms(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "open", "alice", true, true).await; + seed_membership(&pool, "open", "bob").await; + seed_room(&pool, "listed", "alice", false, true).await; + seed_room(&pool, "secret", "alice", false, false).await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &list_cmd()).await; + let event = next_event(&mut ws).await; + let rooms = match event { + ServerEvent::DiscoverableRooms { rooms } => rooms, + other => panic!("expected DiscoverableRooms, got {other:?}"), + }; + + let names: Vec<&str> = rooms.iter().map(|r| r.room_name.as_str()).collect(); + assert!(names.contains(&"open"), "public room should be listed"); + assert!( + names.contains(&"listed"), + "discoverable room should be listed" + ); + assert!(!names.contains(&"secret"), "hidden room must not appear"); + assert_eq!(rooms.len(), 2); + + let open = rooms.iter().find(|r| r.room_name == "open").unwrap(); + assert!(open.is_public); + assert_eq!(open.member_count, 2); + + let listed = rooms.iter().find(|r| r.room_name == "listed").unwrap(); + assert!(!listed.is_public); + assert_eq!(listed.member_count, 1); + + close_socket(&mut ws).await; +} diff --git a/tests/max_chunk_size.rs b/tests/max_chunk_size.rs index 1080331..0f90014 100644 --- a/tests/max_chunk_size.rs +++ b/tests/max_chunk_size.rs @@ -18,17 +18,20 @@ fn framework_default_payload() -> usize { // Declare a single-chunk attachment on a fresh message in `room_id`, sized and // hashed for `payload`, and return its attachment_id. The bytes still have to be // streamed up separately; this just creates the (incomplete) row to stream into. -async fn declare_single_chunk(ws: &mut Ws, room_id: Uuid, payload: &[u8]) -> Uuid { +async fn declare_single_chunk(ws: &mut Ws, room_name: &str, payload: &[u8]) -> Uuid { let mut hasher = Sha256::new(); hasher.update(payload); send_cmd( ws, &ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: "file".to_owned(), attachments: vec![NewMessageAttachment { - filename: "f.bin".to_owned(), - content_type: "application/octet-stream".to_owned(), + // These chunk-sizing tests use repeated 0x07 bytes, which have no + // magic signature and no NUL, so the content-type policy accepts + // them as the declared text type. Content is incidental here. + filename: "f.txt".to_owned(), + content_type: "text/plain".to_owned(), size_bytes: payload.len() as i64, chunk_count: 1, content_sha256: hasher.finalize().to_vec(), @@ -95,9 +98,8 @@ async fn chunk_exactly_at_limit_completes(pool: PgPool) { let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - let rid = room_id(&pool, "general").await; let payload = vec![7u8; 4096]; - let attachment_id = declare_single_chunk(&mut ws, rid, &payload).await; + let attachment_id = declare_single_chunk(&mut ws, "general", &payload).await; send_chunk_frame(&mut ws, attachment_id, 0, &payload).await; match next_reply(&mut ws).await { @@ -120,9 +122,8 @@ async fn chunk_one_byte_over_limit_drops_the_connection(pool: PgPool) { let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - let rid = room_id(&pool, "general").await; let payload = vec![7u8; 4097]; - let attachment_id = declare_single_chunk(&mut ws, rid, &payload).await; + let attachment_id = declare_single_chunk(&mut ws, "general", &payload).await; send_chunk_frame(&mut ws, attachment_id, 0, &payload).await; // We never get a normal ServerEvent back -- the connection closes. The drop @@ -158,9 +159,8 @@ async fn chunk_larger_than_framework_default_is_accepted_when_configured(pool: P let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - let rid = room_id(&pool, "general").await; let payload = vec![7u8; 17 * 1024 * 1024]; - let attachment_id = declare_single_chunk(&mut ws, rid, &payload).await; + let attachment_id = declare_single_chunk(&mut ws, "general", &payload).await; send_chunk_frame(&mut ws, attachment_id, 0, &payload).await; match next_reply(&mut ws).await { diff --git a/tests/reactions.rs b/tests/reactions.rs index 00b9b51..e124dcc 100644 --- a/tests/reactions.rs +++ b/tests/reactions.rs @@ -16,11 +16,10 @@ async fn post_message( let mut ws = create_socket(server.addr).await; authenticate(&mut ws, username, password).await; - let room_id = room_id(&server.pool, room_name).await; send_cmd( &mut ws, &ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: "hello".to_owned(), attachments: vec![], }, diff --git a/tests/read_state.rs b/tests/read_state.rs index 37b095a..98e9e72 100644 --- a/tests/read_state.rs +++ b/tests/read_state.rs @@ -11,11 +11,11 @@ async fn login(server: &TestServer, username: &str, password: &str) -> Ws { ws } -async fn post(ws: &mut Ws, room_id: Uuid, content: &str) -> Uuid { +async fn post(ws: &mut Ws, room_name: &str, content: &str) -> Uuid { send_cmd( ws, &ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: content.to_owned(), attachments: vec![], }, @@ -74,11 +74,10 @@ async fn own_sends_are_not_unread_to_the_sender(pool: PgPool) { seed_user(&pool, "alice", "alicepw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; - post(&mut alice, room, "one").await; - post(&mut alice, room, "two").await; + post(&mut alice, "vault", "one").await; + post(&mut alice, "vault", "two").await; // Sending advances the sender's own watermark, so the room reads as caught up. assert_eq!(unread_for(&mut alice, "vault").await, 0); @@ -90,11 +89,10 @@ async fn new_member_starts_caught_up_then_accrues_unread(pool: PgPool) { seed_user(&pool, "bob", "bobpw").await; seed_room(&pool, "vault", "alice", true, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; - post(&mut alice, room, "before bob 1").await; - post(&mut alice, room, "before bob 2").await; + post(&mut alice, "vault", "before bob 1").await; + post(&mut alice, "vault", "before bob 2").await; // Bob joins after the backlog exists: caught up, not a wall of unread. let mut bob = login(&server, "bob", "bobpw").await; @@ -102,7 +100,7 @@ async fn new_member_starts_caught_up_then_accrues_unread(pool: PgPool) { assert_eq!(unread_for(&mut bob, "vault").await, 0); // Messages sent after he joined are unread to him. - post(&mut alice, room, "after bob").await; + post(&mut alice, "vault", "after bob").await; assert_eq!(unread_for(&mut bob, "vault").await, 1); } @@ -112,14 +110,13 @@ async fn mark_read_clears_unread(pool: PgPool) { seed_user(&pool, "bob", "bobpw").await; seed_room(&pool, "vault", "alice", true, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; let mut bob = login(&server, "bob", "bobpw").await; join(&mut bob, "vault").await; - post(&mut alice, room, "m1").await; - let newest = post(&mut alice, room, "m2").await; + post(&mut alice, "vault", "m1").await; + let newest = post(&mut alice, "vault", "m2").await; assert_eq!(unread_for(&mut bob, "vault").await, 2); mark_read(&mut bob, "vault", newest).await; @@ -132,14 +129,13 @@ async fn mark_read_is_forward_only(pool: PgPool) { seed_user(&pool, "bob", "bobpw").await; seed_room(&pool, "vault", "alice", true, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; let mut bob = login(&server, "bob", "bobpw").await; join(&mut bob, "vault").await; - let older = post(&mut alice, room, "m1").await; - let newest = post(&mut alice, room, "m2").await; + let older = post(&mut alice, "vault", "m1").await; + let newest = post(&mut alice, "vault", "m2").await; mark_read(&mut bob, "vault", newest).await; assert_eq!(unread_for(&mut bob, "vault").await, 0); @@ -156,10 +152,9 @@ async fn non_member_cannot_mark_read(pool: PgPool) { seed_user(&pool, "mallory", "mallorypw").await; seed_room(&pool, "vault", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let room = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; - let message_id = post(&mut alice, room, "secret").await; + let message_id = post(&mut alice, "vault", "secret").await; let mut mallory = login(&server, "mallory", "mallorypw").await; send_cmd( @@ -180,7 +175,6 @@ async fn summary_lists_every_room_including_zero_unread(pool: PgPool) { seed_room(&pool, "vault", "alice", true, false).await; seed_room(&pool, "lounge", "alice", false, false).await; let server = spawn_app(pool.clone(), |_| {}).await; - let vault = room_id(&pool, "vault").await; let mut alice = login(&server, "alice", "alicepw").await; let mut bob = login(&server, "bob", "bobpw").await; @@ -188,8 +182,8 @@ async fn summary_lists_every_room_including_zero_unread(pool: PgPool) { // Bob posts in vault; alice never read or sent there, so it's unread to her. // alice's lounge has no messages -> still listed, at zero. - post(&mut bob, vault, "hi").await; - post(&mut bob, vault, "again").await; + post(&mut bob, "vault", "hi").await; + post(&mut bob, "vault", "again").await; let rooms = summary(&mut alice).await; // Ordered by room_name: lounge before vault. diff --git a/tests/reject_attachment.rs b/tests/reject_attachment.rs new file mode 100644 index 0000000..48b17c2 --- /dev/null +++ b/tests/reject_attachment.rs @@ -0,0 +1,236 @@ +mod common; + +use common::*; +use relay::model::NewMessageAttachment; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use uuid::Uuid; + +// A minimal xz stream header: the 6-byte magic (FD 37 7A 58 5A 00) is enough for +// magic-byte sniffing to recognize it as application/x-xz, which is not in the +// supported-binary allowlist and so must be rejected. +fn xz_bytes() -> Vec { + vec![0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x01, 0x02, 0x03, 0x04] +} + +// Declare a single-chunk attachment on a fresh message and return its id. The +// bytes are streamed separately; this only creates the (incomplete) row. +async fn declare(ws: &mut Ws, room_name: &str, declared_type: &str, payload: &[u8]) -> Uuid { + let mut hasher = Sha256::new(); + hasher.update(payload); + send_cmd( + ws, + &ClientCommand::SendMessage { + room_name: room_name.to_owned(), + content: "file".to_owned(), + attachments: vec![NewMessageAttachment { + filename: "archive.xz".to_owned(), + content_type: declared_type.to_owned(), + size_bytes: payload.len() as i64, + chunk_count: 1, + content_sha256: hasher.finalize().to_vec(), + }], + }, + ) + .await; + match next_reply(ws).await { + ServerEvent::MessageCreated { attachment_ids, .. } => attachment_ids[0], + other => panic!("expected MessageCreated, got {other:?}"), + } +} + +async fn attachment_exists(pool: &PgPool, id: Uuid) -> bool { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM message_attachments WHERE attachment_id = $1", + ) + .bind(id) + .fetch_one(pool) + .await + .expect("count attachments") + > 0 +} + +async fn chunk_count(pool: &PgPool, id: Uuid) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM message_attachment_chunks WHERE attachment_id = $1") + .bind(id) + .fetch_one(pool) + .await + .expect("count chunks") +} + +async fn expect_rejection_and_removal(ws: &mut Ws, room: &str, attachment_id: Uuid) -> String { + let mut reason = None; + let mut removed = false; + while reason.is_none() || !removed { + match next_reply(ws).await { + ServerEvent::AttachmentRejected { + attachment_id: id, + reason: r, + } => { + assert_eq!(id, attachment_id); + reason = Some(r); + } + ServerEvent::MessageRemoved { room_name, .. } => { + assert_eq!(room_name, room); + removed = true; + } + other => panic!("expected AttachmentRejected or MessageRemoved, got {other:?}"), + } + } + reason.unwrap() +} + +// A fully-uploaded but unsupported file (xz archive) is rejected by the +// content-type policy, and the rejection cancels the upload: the attachment row +// and all of its chunks are deleted from the database rather than left incomplete +// for the reaper. The parent message keeps its caption text. +#[sqlx::test] +async fn rejected_upload_is_deleted(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "general", "alice", true, true).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + let payload = xz_bytes(); + let attachment_id = declare(&mut ws, "general", "application/x-xz", &payload).await; + send_chunk_frame(&mut ws, attachment_id, 0, &payload).await; + + // The rejection and removal are live events on the uploader's room stream. + let reason = expect_rejection_and_removal(&mut ws, "general", attachment_id).await; + assert!(reason.contains("x-xz"), "reason should name the type"); + + // The cancelled upload leaves nothing behind: the attachment row is gone, its + // chunks cascaded away, and the file-only parent message (whose caption was + // just the filename) is deleted rather than left as a bare filename for the room. + assert!( + !attachment_exists(&pool, attachment_id).await, + "attachment row should be deleted" + ); + assert_eq!( + chunk_count(&pool, attachment_id).await, + 0, + "chunks should be deleted" + ); + let messages: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages") + .fetch_one(&pool) + .await + .expect("count messages"); + assert_eq!(messages, 0, "the orphaned file-only message is deleted too"); + + close_socket(&mut ws).await; +} + +// Rejected attachment cleanup must reach other room members that already rendered +// the incomplete message. They need AttachmentRejected to drop the dead attachment +// and MessageRemoved when that rejection empties the message. +#[sqlx::test] +async fn rejected_upload_cleanup_is_broadcast_to_room_members(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "general", "alice", true, true).await; + seed_membership(&pool, "general", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut alice = create_socket(server.addr).await; + authenticate(&mut alice, "alice", "alicepw").await; + + let mut bob = create_socket(server.addr).await; + authenticate(&mut bob, "bob", "bobpw").await; + send_cmd(&mut bob, &ClientCommand::GetUnreadSummary).await; + match next_reply(&mut bob).await { + ServerEvent::UnreadSummary { .. } => {} + other => panic!("expected UnreadSummary, got {other:?}"), + } + + let payload = xz_bytes(); + let attachment_id = declare(&mut alice, "general", "application/x-xz", &payload).await; + send_chunk_frame(&mut alice, attachment_id, 0, &payload).await; + + let mut saw_live_message = false; + let mut saw_rejected = false; + let mut saw_removed = false; + while !saw_rejected || !saw_removed { + match next_event(&mut bob).await { + ServerEvent::NewMessage { room_name, message } => { + assert_eq!(room_name, "general"); + assert!( + message + .attachments + .iter() + .any(|a| a.attachment_id == attachment_id) + ); + saw_live_message = true; + } + ServerEvent::AttachmentRejected { + attachment_id: id, .. + } => { + assert_eq!(id, attachment_id); + saw_rejected = true; + } + ServerEvent::MessageRemoved { room_name, .. } => { + assert_eq!(room_name, "general"); + saw_removed = true; + } + ServerEvent::Resync { .. } => {} + other => panic!("expected live cleanup event, got {other:?}"), + } + } + assert!( + saw_live_message, + "bob should have seen the message before cleanup" + ); + + let reason = expect_rejection_and_removal(&mut alice, "general", attachment_id).await; + assert!(reason.contains("x-xz"), "reason should name the type"); + + close_socket(&mut alice).await; + close_socket(&mut bob).await; +} + +// Smuggling: a binary file (xz) declared as text/plain is also rejected and +// cancelled. This path goes through the magicless/declared branch only when the +// bytes have no signature; xz does have one, so detection wins and the file is +// rejected as its true type regardless of the false text declaration. +#[sqlx::test] +async fn mislabeled_binary_is_deleted(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_room(&pool, "general", "alice", true, true).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + let payload = xz_bytes(); + let attachment_id = declare(&mut ws, "general", "text/plain", &payload).await; + send_chunk_frame(&mut ws, attachment_id, 0, &payload).await; + + // Detection wins over the false text declaration: rejected as its true type. + let mut rejected = false; + let mut removed = false; + while !rejected || !removed { + match next_reply(&mut ws).await { + ServerEvent::AttachmentRejected { + attachment_id: id, .. + } => { + assert_eq!(id, attachment_id); + rejected = true; + } + ServerEvent::MessageRemoved { .. } => removed = true, + other => panic!("expected AttachmentRejected or MessageRemoved, got {other:?}"), + } + } + + assert!( + !attachment_exists(&pool, attachment_id).await, + "attachment row should be deleted" + ); + assert_eq!( + chunk_count(&pool, attachment_id).await, + 0, + "chunks should be deleted" + ); + + close_socket(&mut ws).await; +} diff --git a/tests/remove_room_member.rs b/tests/remove_room_member.rs new file mode 100644 index 0000000..9e6b61a --- /dev/null +++ b/tests/remove_room_member.rs @@ -0,0 +1,125 @@ +mod common; + +use common::*; +use sqlx::PgPool; + +fn remove_cmd(room: &str, member: &str) -> ClientCommand { + ClientCommand::RemoveRoomMember { + room_name: room.to_owned(), + member_username: member.to_owned(), + } +} + +// An owner removes another member: the membership is gone. +#[sqlx::test] +async fn owner_removes_member(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", false, false).await; + seed_membership(&pool, "club", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + assert!(is_member(&pool, "club", "bob").await); + send_cmd(&mut ws, &remove_cmd("club", "bob")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Success); + assert!(!is_member(&pool, "club", "bob").await); + + close_socket(&mut ws).await; +} + +// An admin can remove a member from a room they don't own. +#[sqlx::test] +async fn admin_removes_member(pool: PgPool) { + seed_admin(&pool, "admin", "adminpw").await; + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", false, false).await; + seed_membership(&pool, "club", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "admin", "adminpw").await; + + send_cmd(&mut ws, &remove_cmd("club", "bob")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Success); + assert!(!is_member(&pool, "club", "bob").await); + + close_socket(&mut ws).await; +} + +// A non-owner member can't remove anyone; the target stays a member. "No such +// room" and "not authorized" are reported identically (Failed) so a non-owner +// can't probe a private room's existence. +#[sqlx::test] +async fn non_owner_cannot_remove(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_user(&pool, "carol", "carolpw").await; + seed_room(&pool, "club", "alice", false, false).await; + seed_membership(&pool, "club", "bob").await; + seed_membership(&pool, "club", "carol").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "bob", "bobpw").await; + + send_cmd(&mut ws, &remove_cmd("club", "carol")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::Failed); + assert!(is_member(&pool, "club", "carol").await); + + close_socket(&mut ws).await; +} + +// Removing someone who isn't a member is a no-op. +#[sqlx::test] +async fn removing_non_member_no_change(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", false, false).await; + let server = spawn_app(pool.clone(), |_| {}).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "alice", "alicepw").await; + + send_cmd(&mut ws, &remove_cmd("club", "bob")).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::NoChange); + + close_socket(&mut ws).await; +} + +// A removed member can no longer post to the room: the JIT membership check on +// send now fails. +#[sqlx::test] +async fn removed_member_cannot_post(pool: PgPool) { + seed_user(&pool, "alice", "alicepw").await; + seed_user(&pool, "bob", "bobpw").await; + seed_room(&pool, "club", "alice", true, false).await; + seed_membership(&pool, "club", "bob").await; + let server = spawn_app(pool.clone(), |_| {}).await; + + // Owner removes bob. + let mut alice = create_socket(server.addr).await; + authenticate(&mut alice, "alice", "alicepw").await; + send_cmd(&mut alice, &remove_cmd("club", "bob")).await; + assert_eq!(next_event(&mut alice).await, ServerEvent::Success); + + // bob, on his own session, can no longer post. + let mut bob = create_socket(server.addr).await; + authenticate(&mut bob, "bob", "bobpw").await; + send_cmd( + &mut bob, + &ClientCommand::SendMessage { + room_name: "club".to_owned(), + content: "still here?".to_owned(), + attachments: vec![], + }, + ) + .await; + assert_eq!(next_event(&mut bob).await, ServerEvent::Failed); + + close_socket(&mut alice).await; + close_socket(&mut bob).await; +} diff --git a/tests/send_message.rs b/tests/send_message.rs index 4b4df1c..4dba103 100644 --- a/tests/send_message.rs +++ b/tests/send_message.rs @@ -6,12 +6,12 @@ use sqlx::PgPool; use uuid::Uuid; fn send_message_cmd( - room_id: Uuid, + room_name: &str, content: &str, attachments: Vec, ) -> ClientCommand { ClientCommand::SendMessage { - room_id, + room_name: room_name.to_owned(), content: content.to_owned(), attachments, } @@ -37,7 +37,7 @@ async fn member_can_post_plain_message(pool: PgPool) { authenticate(&mut ws, "alice", "alicepw").await; let rid = room_id(&pool, "general").await; - send_cmd(&mut ws, &send_message_cmd(rid, "hello world", vec![])).await; + send_cmd(&mut ws, &send_message_cmd("general", "hello world", vec![])).await; match next_reply(&mut ws).await { ServerEvent::MessageCreated { @@ -79,12 +79,15 @@ async fn attachments_are_created_incomplete_in_order(pool: PgPool) { let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - let rid = room_id(&pool, "general").await; let attachments = vec![ attachment("first.bin", 2, 1024), attachment("second.bin", 5, 4096), ]; - send_cmd(&mut ws, &send_message_cmd(rid, "see files", attachments)).await; + send_cmd( + &mut ws, + &send_message_cmd("general", "see files", attachments), + ) + .await; let (message_id, attachment_ids) = match next_reply(&mut ws).await { ServerEvent::MessageCreated { @@ -135,7 +138,7 @@ async fn non_member_cannot_post(pool: PgPool) { authenticate(&mut ws, "mallory", "mallorypw").await; let rid = room_id(&pool, "secret").await; - send_cmd(&mut ws, &send_message_cmd(rid, "let me in", vec![])).await; + send_cmd(&mut ws, &send_message_cmd("secret", "let me in", vec![])).await; assert_eq!(next_reply(&mut ws).await, ServerEvent::Failed); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE room_id = $1") @@ -156,10 +159,7 @@ async fn unknown_room_is_indistinguishable_from_forbidden(pool: PgPool) { let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - // A room that doesn't exist: same generic Failed as a forbidden room, so - // existence isn't leaked. - let missing = Uuid::parse_str("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap(); - send_cmd(&mut ws, &send_message_cmd(missing, "anyone?", vec![])).await; + send_cmd(&mut ws, &send_message_cmd("missing", "anyone?", vec![])).await; assert_eq!(next_reply(&mut ws).await, ServerEvent::Failed); close_socket(&mut ws).await; @@ -174,13 +174,12 @@ async fn empty_content_is_rejected_and_session_survives(pool: PgPool) { let mut ws = create_socket(server.addr).await; authenticate(&mut ws, "alice", "alicepw").await; - let rid = room_id(&pool, "general").await; // Empty content violates the messages.content CHECK -> Failed. - send_cmd(&mut ws, &send_message_cmd(rid, "", vec![])).await; + send_cmd(&mut ws, &send_message_cmd("general", "", vec![])).await; assert_eq!(next_reply(&mut ws).await, ServerEvent::Failed); // The session is still usable afterward. - send_cmd(&mut ws, &send_message_cmd(rid, "recovered", vec![])).await; + send_cmd(&mut ws, &send_message_cmd("general", "recovered", vec![])).await; assert!(matches!( next_reply(&mut ws).await, ServerEvent::MessageCreated { .. } @@ -202,7 +201,7 @@ async fn malformed_attachment_rolls_back_the_message(pool: PgPool) { // size_bytes = 0 violates the message_attachments CHECK; the whole insert // (message included) must roll back. let bad = vec![attachment("empty.bin", 1, 0)]; - send_cmd(&mut ws, &send_message_cmd(rid, "has a bad file", bad)).await; + send_cmd(&mut ws, &send_message_cmd("general", "has a bad file", bad)).await; assert_eq!(next_reply(&mut ws).await, ServerEvent::Failed); let msg_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE room_id = $1") diff --git a/tests/signup_status.rs b/tests/signup_status.rs new file mode 100644 index 0000000..ff55dae --- /dev/null +++ b/tests/signup_status.rs @@ -0,0 +1,94 @@ +mod common; + +use common::*; +use sqlx::PgPool; + +// A client can query signup status in the prelude, before authenticating, so a +// login screen knows whether to offer registration. Default config has signups +// closed. +#[sqlx::test] +async fn reports_signups_closed_pre_auth(pool: PgPool) { + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + send_cmd(&mut ws, &ClientCommand::GetSignupStatus).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::SignupStatus { + open_signups: false + } + ); + // (no graceful close: pre-auth Close yields NoAuth; just drop the socket) +} + +#[sqlx::test] +async fn reports_signups_open_pre_auth(pool: PgPool) { + let server = spawn_app(pool, |c| c.open_signups = true).await; + + let mut ws = create_socket(server.addr).await; + send_cmd(&mut ws, &ClientCommand::GetSignupStatus).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::SignupStatus { open_signups: true } + ); +} + +// The query is non-terminal: after answering it, the prelude keeps waiting, so the +// same socket can still authenticate. +#[sqlx::test] +async fn query_does_not_consume_the_prelude(pool: PgPool) { + seed_admin(&pool, "boss", "bosspw").await; + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + send_cmd(&mut ws, &ClientCommand::GetSignupStatus).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::SignupStatus { + open_signups: false + } + ); + + // Still in the prelude — authenticate on the same socket and proceed. + authenticate(&mut ws, "boss", "bosspw").await; + close_socket(&mut ws).await; +} + +// A query keeps the prelude open, but any other (non-auth, non-signup) command +// then still ends the connection — an unauthenticated socket can't linger or do +// anything but query, auth, or sign up. +#[sqlx::test] +async fn non_prelude_command_still_closes(pool: PgPool) { + let server = spawn_app(pool, |_| {}).await; + + let mut ws = create_socket(server.addr).await; + send_cmd(&mut ws, &ClientCommand::GetSignupStatus).await; + assert_eq!( + next_event(&mut ws).await, + ServerEvent::SignupStatus { + open_signups: false + } + ); + + // A command that isn't Auth / NewUser / GetSignupStatus terminates the prelude. + send_cmd(&mut ws, &ClientCommand::GetUnreadSummary).await; + assert_eq!(next_event(&mut ws).await, ServerEvent::NoAuth); + expect_close(&mut ws).await; +} + +// The same query also works after authentication (it's a plain command too). +#[sqlx::test] +async fn query_works_post_auth(pool: PgPool) { + seed_admin(&pool, "boss", "bosspw").await; + let server = spawn_app(pool, |c| c.open_signups = true).await; + + let mut ws = create_socket(server.addr).await; + authenticate(&mut ws, "boss", "bosspw").await; + + send_cmd(&mut ws, &ClientCommand::GetSignupStatus).await; + assert_eq!( + next_reply(&mut ws).await, + ServerEvent::SignupStatus { open_signups: true } + ); + close_socket(&mut ws).await; +}