diff --git a/.env.example b/.env.example index 1390efc..04a1dd8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz PORT=3000 -APP_BASE_URL=http://localhost:3000 # Authentik OIDC app values OIDC_CLIENT_ID=replace-me @@ -17,9 +16,6 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr TURN_USERNAME=replace-me TURN_PASSWORD=replace-me -# Cloudflare R2 media uploads -R2_ACCOUNT_ID=replace-me -R2_ACCESS_KEY_ID=replace-me -R2_SECRET_ACCESS_KEY=replace-me -R2_BUCKET=chattz-media -MEDIA_BASE_URL=https://media.example.com +# 32+ random chars; used to sign session cookies +SESSION_SECRET=replace-with-long-random-secret +COOKIE_SECURE=false diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index c76ae86..089f873 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -19,6 +19,17 @@ jobs: VERSION=${GITHUB_REF_NAME#v} echo "Bumping version to $VERSION" npm version $VERSION --no-git-tag-version + + # Configure Git + git config user.name "Forgejo Actions" + git config user.email "actions@noreply.flegr.me" + + # Commit and push back to main if version changed + if [ -n "$(git status --porcelain package.json)" ]; then + git add package.json package-lock.json + git commit -m "chore: bump version to $VERSION [skip ci]" + git push origin HEAD:main + fi - run: npm ci - run: npm run dist:linux - name: Build Windows installer with 32-bit Wine prefix diff --git a/Cargo.lock b/Cargo.lock index c44764d..190bd84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,418 +92,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-config" -version = "1.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http 0.63.5", - "aws-smithy-json 0.62.4", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 1.4.0", - "time", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "aws-credential-types" -version = "1.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - -[[package]] -name = "aws-lc-rs" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "aws-runtime" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" -dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http 0.63.5", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "bytes-utils", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", -] - -[[package]] -name = "aws-sdk-s3" -version = "1.119.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-checksums", - "aws-smithy-eventstream", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "bytes", - "fastrand", - "hex", - "hmac", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "lru", - "percent-encoding", - "regex-lite", - "sha2", - "tracing", - "url", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.99.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http 0.63.5", - "aws-smithy-json 0.62.4", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sigv4" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" -dependencies = [ - "aws-credential-types", - "aws-smithy-eventstream", - "aws-smithy-http 0.63.5", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "form_urlencoded", - "hex", - "hmac", - "http 0.2.12", - "http 1.4.0", - "percent-encoding", - "sha2", - "time", - "tracing", -] - -[[package]] -name = "aws-smithy-async" -version = "1.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-checksums" -version = "0.63.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" -dependencies = [ - "aws-smithy-http 0.62.6", - "aws-smithy-types", - "bytes", - "crc-fast", - "hex", - "http 0.2.12", - "http-body 0.4.6", - "md-5", - "pin-project-lite", - "sha1", - "sha2", - "tracing", -] - -[[package]] -name = "aws-smithy-eventstream" -version = "0.60.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] - -[[package]] -name = "aws-smithy-http" -version = "0.62.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" -dependencies = [ - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http" -version = "0.63.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http-client" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.8.1", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.7", - "hyper-util", - "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.36", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tower", - "tracing", -] - -[[package]] -name = "aws-smithy-json" -version = "0.61.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-json" -version = "0.62.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-observability" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" -dependencies = [ - "aws-smithy-runtime-api", -] - -[[package]] -name = "aws-smithy-query" -version = "0.60.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" -dependencies = [ - "aws-smithy-types", - "urlencoding", -] - -[[package]] -name = "aws-smithy-runtime" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" -dependencies = [ - "aws-smithy-async", - "aws-smithy-http 0.63.5", - "aws-smithy-http-client", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" -dependencies = [ - "aws-smithy-async", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.4.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-types" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", - "tokio", - "tokio-util", -] - -[[package]] -name = "aws-smithy-xml" -version = "0.60.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" -dependencies = [ - "xmlparser", -] - -[[package]] -name = "aws-types" -version = "1.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" -dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "rustc_version", - "tracing", -] - [[package]] name = "axum" version = "0.8.8" @@ -516,10 +104,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -550,8 +138,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", @@ -578,16 +166,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - [[package]] name = "base64ct" version = "1.8.3" @@ -630,16 +208,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - [[package]] name = "cc" version = "1.2.56" @@ -647,8 +215,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -669,20 +235,17 @@ name = "chattz" version = "0.1.0" dependencies = [ "anyhow", - "aws-config", - "aws-sdk-s3", "axum", "chrono", "dotenvy", "futures-util", + "jsonwebtoken", "reqwest", "sea-orm", "sea-orm-migration", "serde", "serde_json", - "sha2", "tokio", - "tower", "tower-http", "tracing", "tracing-subscriber", @@ -704,15 +267,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -728,16 +282,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -768,28 +312,6 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" -[[package]] -name = "crc-fast" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" -dependencies = [ - "crc", - "digest", - "rand 0.9.2", - "regex", - "rustversion", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -926,12 +448,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.15.0" @@ -978,12 +494,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1022,12 +532,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.31" @@ -1169,44 +673,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1278,17 +744,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -1299,17 +754,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -1317,7 +761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -1328,8 +772,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -1351,30 +795,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -1385,9 +805,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "httparse", "httpdate", "itoa", @@ -1398,35 +817,19 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", - "rustls 0.23.36", - "rustls-native-certs", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots 1.0.6", ] @@ -1441,14 +844,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio", "tower-service", "tracing", @@ -1629,16 +1032,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.85" @@ -1649,6 +1042,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1712,15 +1120,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -1794,7 +1193,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http", "httparse", "memchr", "mime", @@ -1811,6 +1210,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1869,12 +1278,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "ordered-float" version = "4.6.0" @@ -1908,12 +1311,6 @@ dependencies = [ "syn", ] -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - [[package]] name = "parking" version = "2.2.1" @@ -1943,6 +1340,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2077,8 +1484,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", + "rustls", + "socket2", "thiserror", "tokio", "tracing", @@ -2097,7 +1504,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "slab", "thiserror", @@ -2115,7 +1522,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -2235,12 +1642,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - [[package]] name = "regex-syntax" version = "0.8.9" @@ -2256,25 +1657,25 @@ dependencies = [ "base64", "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2334,45 +1735,20 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2383,23 +1759,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2417,31 +1782,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sea-bae" version = "0.2.1" @@ -2589,29 +1935,6 @@ dependencies = [ "syn", ] -[[package]] -name = "security-framework" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.27" @@ -2731,6 +2054,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -2746,16 +2081,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.2" @@ -2822,7 +2147,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.36", + "rustls", "serde", "serde_json", "sha2", @@ -3085,6 +2410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -3143,7 +2469,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -3159,23 +2485,13 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", + "rustls", "tokio", ] @@ -3241,8 +2557,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "http-range-header", "httpdate", @@ -3347,7 +2663,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", "rand 0.9.2", @@ -3467,12 +2783,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "want" version = "0.3.1" @@ -3903,12 +3213,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 6dbd768..5ab0c14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,20 +5,17 @@ edition = "2024" [dependencies] anyhow = "1" -aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] } -aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] } axum = { version = "0.8", features = ["macros", "ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" +jsonwebtoken = "9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] } +sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.10" futures-util = "0.3" -tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] } -tower = { version = "0.5", features = ["util"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tower-http = { version = "0.6", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/README.md b/README.md index 2708a60..af5e8f0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using: ## What this includes - OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`) -- HttpOnly session cookie auth +- Signed session cookie auth - Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling - Guild invite codes (create + join) - Direct messages (DM) between users @@ -41,11 +41,6 @@ For voice reliability on restrictive networks, configure TURN in `.env`: - `TURN_USERNAME` - `TURN_PASSWORD` -For production deployments: -- `APP_BASE_URL` must be your public app origin and should use `https` -- `MEDIA_BASE_URL` should be a separate media origin for user uploads -- uploads and soundboard require R2/object storage to be configured - 3. Run app: ```bash @@ -60,7 +55,7 @@ Web UI is available at `http://localhost:${PORT}/`. ## Authentik setup notes Create an Authentik OAuth2/OIDC provider + application and set: -- Redirect URI: `${APP_BASE_URL}/auth/callback` +- Redirect URI: `http://localhost:3000/auth/callback` - Scopes including at least: `openid profile email` If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match. @@ -96,7 +91,6 @@ For Authentik these are commonly under `/application/o/...` for the app slug. - `GET /channels/:channel_id/voice/ws` (WebSocket signaling) All endpoints except health and auth flow require the session cookie from successful login. -Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session. ## Notes @@ -104,7 +98,6 @@ This is intentionally minimal and monolithic (single process, single Postgres in Voice is implemented as browser-to-browser WebRTC audio with signaling in this server. For two users behind strict NAT/firewall, you may need TURN for reliable connectivity. The web UI remembers the last selected guild in browser local storage and auto-selects it on reload. -User uploads are served from the configured media origin, not from `/static`. Mic filter modes in the UI: - `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset) diff --git a/desktop/index.html b/desktop/index.html index 1684077..ed754ec 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -1,4 +1,3 @@ - @@ -6,9 +5,12 @@ Chattz - + + + + - + @@ -114,7 +116,8 @@
@@ -141,10 +144,6 @@
- - @@ -266,17 +265,7 @@
- - - + \ No newline at end of file diff --git a/desktop/preload.js b/desktop/preload.js index f43670e..dfcc665 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,20 +1,18 @@ const { contextBridge, ipcRenderer } = require('electron'); -function onIpc(channel, callback) { - const listener = (_event, payload) => callback(payload); - ipcRenderer.on(channel, listener); - return () => ipcRenderer.removeListener(channel, listener); -} - contextBridge.exposeInMainWorld('electronAPI', { copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text), + getConfig: () => ipcRenderer.invoke('get-config'), + storageGet: (key) => ipcRenderer.invoke('storage-get', key), + storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), + storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), getUpdateState: () => ipcRenderer.invoke('get-update-state'), checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'), checkForUpdates: () => ipcRenderer.send('check-for-updates'), downloadUpdate: () => ipcRenderer.send('download-update'), quitAndInstall: () => ipcRenderer.send('quit-and-install'), - onUpdateState: (callback) => onIpc('update-state', callback), - onUpdateAvailable: (callback) => onIpc('update-available', callback), - onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback), - onUpdateError: (callback) => onIpc('update-error', callback) + onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)), + onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), + onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), + onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) }); diff --git a/desktop/styles.css b/desktop/styles.css new file mode 100644 index 0000000..fda1104 --- /dev/null +++ b/desktop/styles.css @@ -0,0 +1,1629 @@ +:root { + /* ── Backgrounds ─────────────────────────────────────────── */ + --bg-darker: #16171b; + --bg-sidebar: #1e2025; + --bg-main: #23252b; + --bg-secondary: #1a1c21; + --bg-tertiary: #111216; + --bg-modifier-selected: rgba(99, 102, 241, 0.15); + --bg-modifier-hover: rgba(255, 255, 255, 0.04); + --bg-input: #2a2d35; + + /* ── Text ────────────────────────────────────────────────── */ + --text-normal: #c9cdd4; + --text-muted: #7c818a; + --text-strong: #eef0f4; + --text-link: #818cf8; + + /* ── Accents ─────────────────────────────────────────────── */ + --brand: #6366f1; + --brand-hover: #4f46e5; + --brand-gradient: linear-gradient(135deg, #6366f1, #a855f7); + --brand-glow: rgba(99, 102, 241, 0.35); + --green: #22c55e; + --danger: #ef4444; + --yellow: #f59e0b; + + /* ── Misc ────────────────────────────────────────────────── */ + --font-main: "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, + sans-serif; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Reset & Base */ +/* ═══════════════════════════════════════════════════════════ */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + height: 100vh; + height: 100dvh; + background: var(--bg-darker); + color: var(--text-normal); + font-family: var(--font-main); + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Scrollbars ────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 100px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.14); +} + +/* ── Base Elements ─────────────────────────────────────────── */ +button { + border: 0; + cursor: pointer; + transition: all var(--transition); + background: none; + color: inherit; + font: inherit; + padding: 0; +} + +input, +select { + border: 0; + outline: none; + background: var(--bg-input); + color: var(--text-normal); + font: inherit; +} + +.hidden { + display: none !important; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Auth Screen */ +/* ═══════════════════════════════════════════════════════════ */ +.auth-screen { + display: grid; + place-items: center; + height: 100vh; + /* Subtle animated gradient background */ + background: linear-gradient(135deg, #0f0c29, #16171b 40%, #1a1c2e 70%, #0f0c29); + background-size: 400% 400%; + animation: gradientShift 12s ease infinite; +} + +@keyframes gradientShift { + + 0%, + 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} + +.auth-card { + width: min(440px, 92vw); + background: rgba(30, 32, 37, 0.75); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-xl); + padding: 40px 36px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5), 0 0 80px rgba(99, 102, 241, 0.08); + text-align: center; +} + +.brand-large { + width: 80px; + height: 80px; + background: var(--brand-gradient); + color: #fff; + border-radius: 22px; + display: grid; + place-items: center; + font-size: 38px; + font-weight: 800; + margin: 0 auto 24px; + box-shadow: 0 4px 24px var(--brand-glow); + transition: transform var(--transition), box-shadow var(--transition); +} + +.brand-large:hover { + transform: scale(1.05); + box-shadow: 0 6px 32px var(--brand-glow); +} + +.auth-card h1 { + margin: 0 0 8px; + color: var(--text-strong); + font-weight: 800; + font-size: 28px; + letter-spacing: -0.5px; +} + +.auth-card p { + margin: 0 0 28px; + color: var(--text-muted); + font-size: 15px; +} + +#login-btn { + width: 100%; + background: var(--brand-gradient); + color: #fff; + padding: 14px; + border-radius: var(--radius-md); + font-weight: 600; + font-size: 16px; + letter-spacing: 0.2px; + box-shadow: 0 2px 16px var(--brand-glow); + transition: all var(--transition); +} + +#login-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 24px var(--brand-glow); + filter: brightness(1.08); +} + +#login-btn:active { + transform: translateY(0); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Main Layout */ +/* ═══════════════════════════════════════════════════════════ */ +.shell { + height: 100vh; + height: 100dvh; + display: grid; + grid-template-columns: 72px 248px 1fr 248px; + background: var(--bg-main); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Server Rail */ +/* ═══════════════════════════════════════════════════════════ */ +.server-rail { + background: var(--bg-darker); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 8px; + overflow-y: auto; + scrollbar-width: none; +} + +.server-rail::-webkit-scrollbar { + display: none; +} + +.guild-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 0; +} + +.brand, +.guild-pill { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + position: relative; + background: var(--bg-sidebar); +} + +.brand { + background: var(--brand-gradient); + color: #fff; + border-radius: 16px; + margin-bottom: 2px; + box-shadow: 0 2px 12px var(--brand-glow); +} + +.brand:hover { + border-radius: 16px; + box-shadow: 0 4px 20px var(--brand-glow); +} + +.separator { + width: 32px; + height: 2px; + background: rgba(255, 255, 255, 0.06); + margin-bottom: 2px; + border-radius: 1px; +} + +.guild-pill:hover, +.guild-pill.active { + border-radius: 16px; + background: var(--brand); + color: #fff; + box-shadow: 0 2px 12px var(--brand-glow); +} + +.guild-pill::before { + content: ""; + position: absolute; + left: -12px; + width: 4px; + height: 0; + background: #fff; + border-radius: 0 4px 4px 0; + transition: all var(--transition); +} + +.guild-pill:hover::before { + height: 20px; +} + +.guild-pill.active::before { + height: 40px; +} + +.action-pill { + color: var(--green); +} + +.action-pill:hover { + background: var(--green); + color: #fff; + box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Channel Sidebar */ +/* ═══════════════════════════════════════════════════════════ */ +.channel-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 0 16px; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: background-color var(--transition); +} + +.sidebar-header:hover { + background: var(--bg-modifier-hover); +} + +.sidebar-header h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.2px; +} + +.sidebar-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.header-action-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: var(--text-muted); + transition: all var(--transition); +} + +.header-action-btn:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +.copied-badge { + background: var(--brand); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.4px; + animation: fadeInOut 2s ease forwards; + pointer-events: none; + white-space: nowrap; + order: -1; + margin-right: 4px; +} + +@keyframes fadeInOut { + 0% { + opacity: 0; + transform: translateY(4px); + } + + 15% { + opacity: 1; + transform: translateY(0); + } + + 85% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 0; + transform: translateY(-4px); + } +} + +.header-action-btn i { + width: 16px; + height: 16px; +} + +.sidebar-scroll { + flex: 1; + overflow-y: auto; + padding-top: 12px; +} + +.sidebar-group { + margin-bottom: 20px; +} + +.group-title { + padding: 0 8px 0 2px; + display: flex; + align-items: center; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: color var(--transition); +} + +.group-title:hover { + color: var(--text-normal); +} + +.group-toggle { + width: 12px; + height: 12px; + margin-right: 2px; +} + +.group-title span { + flex: 1; +} + +.add-btn { + width: 20px; + height: 20px; + border-radius: var(--radius-sm); + opacity: 0.5; + transition: opacity var(--transition), background-color var(--transition), + transform var(--transition); + display: grid; + place-items: center; +} + +.add-btn:hover { + opacity: 1; + background: var(--bg-modifier-hover); + transform: scale(1.1); +} + +.add-btn i { + width: 16px; + height: 16px; +} + +.channel-list { + padding: 0 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.channel-row { + display: flex; + align-items: center; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-weight: 500; + gap: 8px; + transition: all var(--transition); + border-left: 3px solid transparent; +} + +.channel-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.channel-row.active { + background: var(--bg-modifier-selected); + color: var(--text-strong); + border-left-color: var(--brand); +} + +.channel-row i { + width: 20px; + height: 20px; + opacity: 0.5; +} + +.channel-row.active i { + opacity: 0.9; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Sidebar Footer */ +/* ═══════════════════════════════════════════════════════════ */ +.sidebar-footer { + background: var(--bg-secondary); + padding: 0; +} + +.user-panel { + padding: 8px 10px; + display: flex; + align-items: center; + gap: 10px; + height: 52px; + transition: background-color var(--transition); + border-radius: var(--radius-sm); + margin: 2px; +} + +.user-panel:hover { + background: var(--bg-modifier-hover); +} + +.avatar-wrapper { + position: relative; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand-gradient); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; +} + +.status-dot { + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 3px solid var(--bg-secondary); +} + +.status-dot.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +.status-dot.idle { + background: var(--yellow); + box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); +} + +.voice-muted-icon { + width: 14px; + height: 14px; + color: var(--danger); + margin-left: 6px; + vertical-align: text-bottom; +} + +.user-volume-control { + width: 100%; + display: none; + /* Hidden by default */ + align-items: center; + gap: 8px; + padding: 4px 8px 4px 28px; + margin-top: 2px; + box-sizing: border-box; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + overflow: hidden; +} + +.user-volume-control.show-volume { + display: flex; + /* Show when active */ +} + +.volume-slider { + -webkit-appearance: none; + appearance: none; + background: var(--bg-darker); + border-radius: 2px; + outline: none; + flex: 1; + height: 4px; + cursor: pointer; + min-width: 0; +} + +.vol-pct { + font-size: 10px; + opacity: 0.6; + min-width: 30px; + text-align: right; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brand); + cursor: pointer; + box-shadow: 0 0 4px var(--brand-glow); +} + +.volume-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brand); + cursor: pointer; + box-shadow: 0 0 4px var(--brand-glow); +} + +.update-notifier { + display: flex; + align-items: center; + margin-right: 8px; +} + +.update-notifier button { + background: none; + border: none; + padding: 4px; + color: var(--brand); + cursor: pointer; + transition: transform 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.update-notifier button:hover { + transform: scale(1.2); + color: var(--brand-glow); +} + +.update-notifier button i { + width: 16px; + height: 16px; +} + + +.user-info { + flex: 1; + min-width: 0; +} + +.display-name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-status { + font-size: 12px; + color: var(--text-muted); +} + +.user-actions { + display: flex; + gap: 2px; +} + +.user-actions button { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: var(--text-muted); + transition: all var(--transition); +} + +.user-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +.user-actions i { + width: 20px; + height: 20px; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Voice Connection */ +/* ═══════════════════════════════════════════════════════════ */ +.voice-connection { + padding: 10px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.vc-info { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-icon { + color: var(--green); + width: 20px; + filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.4)); +} + +.vc-text { + flex: 1; + display: flex; + flex-direction: column; +} + +.vc-status { + color: var(--green); + font-size: 14px; + font-weight: 700; +} + +.vc-name { + color: var(--text-muted); + font-size: 12px; +} + +.vc-actions { + display: flex; + gap: 4px; +} + +.vc-actions button { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + color: var(--text-muted); + display: grid; + place-items: center; + transition: all var(--transition); +} + +.vc-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Video Grid */ +/* ═══════════════════════════════════════════════════════════ */ +.video-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + padding: 16px; + background: var(--bg-tertiary); + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.video-grid.hidden { + display: none; +} + +.video-item { + position: relative; + aspect-ratio: 16 / 9; + background: #000; + border-radius: var(--radius-lg); + overflow: hidden; + transition: all var(--transition); + cursor: pointer; + z-index: 1; +} + +.video-item:hover { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); + z-index: 2; +} + +.video-item video { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; + pointer-events: none; +} + +/* Fullscreen Feed Support */ +.video-grid.has-fullscreen { + display: flex; + padding: 0; + overflow: hidden; + position: absolute; + top: 48px; + /* Below chat-header */ + left: 0; + right: 0; + bottom: 0; + z-index: 100; + background: var(--bg-main); +} + +.video-grid.has-fullscreen .video-item { + display: none; +} + +.video-grid.has-fullscreen .video-item.fullscreen { + display: block; + flex: 1; + width: 100%; + height: 100%; + border-radius: 0; + cursor: zoom-out; + z-index: 10; + aspect-ratio: auto; +} + +.video-item .video-label { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + color: #fff; + padding: 3px 10px; + border-radius: 100px; + font-size: 12px; + font-weight: 500; + pointer-events: none; + z-index: 5; +} + +/* Voice Activity */ +.voice-speaking .avatar { + box-shadow: 0 0 0 2px var(--green), 0 0 8px rgba(34, 197, 94, 0.3); +} + +.video-item.speaking { + box-shadow: 0 0 0 3px var(--green), 0 0 16px rgba(34, 197, 94, 0.25); +} + +.video-item.speaking .video-label { + background: var(--green); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Chat Input */ +/* ═══════════════════════════════════════════════════════════ */ +.chat-input-wrapper { + padding: 0 16px 24px; +} + +.message-form { + background: var(--bg-input); + border-radius: var(--radius-md); + padding: 2px 12px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); +} + +.input-action-btn { + color: var(--text-muted); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); +} + +.input-action-btn:hover { + color: var(--text-normal); + background: var(--bg-modifier-hover); +} + +.message-form input { + flex: 1; + background: none; + border: 0; + padding: 12px 0; + font-size: 15px; + color: var(--text-normal); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* GIF Picker Modal */ +/* ═══════════════════════════════════════════════════════════ */ +.gif-modal { + width: min(500px, 95vw) !important; + max-height: 600px; + display: flex; + flex-direction: column; + padding: 0 !important; + overflow: hidden; +} + +.gif-modal .modal-header { + padding: 16px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.gif-modal .modal-header h2 { + margin: 0; + font-size: 18px; +} + +.gif-search-wrapper { + padding: 16px 20px; + position: relative; +} + +.gif-search-wrapper input { + width: 100%; + background: var(--bg-darker); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 12px 14px 12px 40px; + color: #fff; + font-size: 14px; + transition: border-color var(--transition); +} + +.gif-search-wrapper input:focus { + border-color: var(--brand); +} + +.gif-search-wrapper .search-icon { + position: absolute; + left: 32px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + color: var(--text-muted); +} + +.gif-results-grid { + flex: 1; + overflow-y: auto; + padding: 0 20px 20px; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-rows: min-content; + gap: 8px; + min-height: 300px; +} + +.gif-item { + border-radius: var(--radius-sm); + overflow: hidden; + cursor: pointer; + background: var(--bg-sidebar); + width: 100%; + aspect-ratio: 16 / 9; + transition: transform var(--transition); +} + +.gif-item:hover { + transform: scale(1.02); + filter: brightness(1.1); +} + +.gif-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.gif-loading { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + color: var(--text-muted); +} + +/* ── GIF in messages ────────────────────────────────────────── */ +.msg-gif { + margin-top: 8px; + max-width: 100%; + max-height: 300px; + border-radius: var(--radius-md); + overflow: hidden; + display: block; +} + +.msg-gif img { + max-width: 100%; + max-height: 300px; + display: block; + object-fit: contain; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Sound Board */ +/* ═══════════════════════════════════════════════════════════ */ +.soundboard-container { + padding: 12px; + background: var(--bg-secondary); + border-top: 1px solid rgba(255, 255, 255, 0.03); + display: flex; + flex-direction: column; + gap: 8px; +} + +.soundboard-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-muted); +} + +.soundboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.sound-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 8px; + background: var(--bg-darker); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition); + border: 1px solid transparent; + position: relative; +} + +.sound-item:hover { + background: var(--bg-modifier-hover); + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.06); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.sound-item:active { + transform: scale(0.95); +} + +.sound-delete { + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: var(--text-muted); + display: grid; + place-items: center; + opacity: 0; + transition: all var(--transition); + z-index: 2; +} + +.sound-delete:hover { + background: var(--danger); + color: #fff; + transform: scale(1.1); +} + +.sound-item:hover .sound-delete { + opacity: 1; +} + +.sound-icon { + font-size: 24px; + display: block; + object-fit: contain; +} + +.sound-name { + font-size: 11px; + text-align: center; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.btn-add-sound { + font-size: 12px; + color: var(--brand); + cursor: pointer; + transition: color var(--transition); +} + +.btn-add-sound:hover { + color: var(--text-link); + text-decoration: underline; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Chat Pane */ +/* ═══════════════════════════════════════════════════════════ */ +.chat-pane { + position: relative; + display: flex; + flex-direction: column; + background: var(--bg-main); + min-width: 0; + min-height: 0; +} + +.chat-header { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); +} + +.header-icon { + color: var(--text-muted); + width: 24px; +} + +.chat-header h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -0.2px; +} + +.message-list { + flex: 1; + overflow-y: scroll; + padding: 16px 0; +} + +.msg { + padding: 4px 16px; + display: flex; + gap: 16px; + margin-top: 1.0625rem; + border-radius: var(--radius-sm); + transition: background-color var(--transition); + border-left: 3px solid transparent; +} + +.msg:hover { + background: rgba(255, 255, 255, 0.015); + border-left-color: rgba(99, 102, 241, 0.3); +} + +.msg-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--brand-gradient); + flex-shrink: 0; + display: grid; + place-items: center; + color: #fff; + font-weight: 600; + font-size: 15px; +} + +.msg-content { + flex: 1; + min-width: 0; +} + +.msg-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.msg-author { + font-weight: 600; + color: var(--text-strong); + cursor: pointer; + font-size: 1rem; + transition: color var(--transition); +} + +.msg-author:hover { + color: var(--text-link); + text-decoration: underline; +} + +.msg-time { + font-size: 11px; + color: var(--text-muted); +} + +.msg-body { + color: var(--text-normal); + line-height: 1.45rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.msg-grouped { + margin-top: 0; + padding-top: 0; + padding-bottom: 0; +} + +.msg-grouped .msg-avatar, +.msg-grouped .msg-header { + display: none; +} + +.msg-grouped .msg-content { + padding-left: 56px; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Chat Input */ +/* ═══════════════════════════════════════════════════════════ */ +.chat-input-wrapper { + padding: 0 16px 24px; + padding-bottom: clamp(24px, calc(24px + env(safe-area-inset-bottom)), 40px); +} + +.message-form { + background: var(--bg-input); + border-radius: var(--radius-lg); + padding: 12px 18px; + border: 1px solid rgba(255, 255, 255, 0.04); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.message-form:focus-within { + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.message-form input { + width: 100%; + background: transparent; + color: var(--text-normal); + font-size: 15px; +} + +.message-form input::placeholder { + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Member List / Utility Sidebar */ +/* ═══════════════════════════════════════════════════════════ */ +.utility-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} + +.member-list-wrapper { + flex: 1; + overflow-y: auto; + padding: 12px 8px; +} + +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition); +} + +.member-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand-gradient); + color: #fff; + display: grid; + place-items: center; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} + +.member-name { + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Presence Badges ───────────────────────────────────────── */ +.avatar-wrapper { + position: relative; + display: inline-block; +} + +.status-badge { + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; +} + +.status-badge.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +.member-avatar, +.msg-avatar { + position: relative; +} + +.status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; +} + +.status-dot.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Modals */ +/* ═══════════════════════════════════════════════════════════ */ +.modal-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: grid; + place-items: center; + z-index: 1000; +} + +.modal { + background: rgba(30, 32, 37, 0.92); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.06); + width: min(460px, 95vw); + border-radius: var(--radius-xl); + padding: 28px; + color: var(--text-normal); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); +} + +.modal h2 { + margin: 0 0 20px; + text-align: center; + color: var(--text-strong); + font-weight: 700; + font-size: 22px; + letter-spacing: -0.3px; +} + +.form-item { + margin-bottom: 20px; +} + +.form-item label { + display: block; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-item input { + width: 100%; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--bg-darker); + border: 1px solid rgba(255, 255, 255, 0.04); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.form-item input:focus { + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.modal-footer { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.cancel-btn { + padding: 10px 20px; + color: var(--text-muted); + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition); +} + +.cancel-btn:hover { + color: var(--text-strong); + background: var(--bg-modifier-hover); +} + +.submit-btn { + background: var(--brand-gradient); + color: #fff; + padding: 10px 24px; + border-radius: var(--radius-md); + font-weight: 600; + box-shadow: 0 2px 12px var(--brand-glow); + transition: all var(--transition); +} + +.submit-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px var(--brand-glow); + filter: brightness(1.06); +} + +.submit-btn:active { + transform: translateY(0); +} + +/* ── Radio Group (Channel Type) ────────────────────────────── */ +.radio-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radio-item { + cursor: pointer; + position: relative; +} + +.radio-item input { + position: absolute; + opacity: 0; +} + +.radio-box { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-modifier-hover); + border-radius: var(--radius-md); + border: 1px solid transparent; + transition: all var(--transition); +} + +.radio-item input:checked+.radio-box { + background: var(--bg-modifier-selected); + border-color: rgba(99, 102, 241, 0.3); + color: var(--text-strong); +} + +.radio-box i { + width: 24px; + height: 24px; + color: var(--text-muted); +} + +.radio-text { + display: flex; + flex-direction: column; +} + +.radio-text strong { + font-size: 16px; +} + +.radio-text span { + font-size: 12px; + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Mobile Overlay */ +/* ═══════════════════════════════════════════════════════════ */ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 90; + opacity: 1; + transition: opacity 0.2s ease; +} + +.overlay.hidden { + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +/* ── Mobile Toggle Buttons ─────────────────────────────────── */ +.mobile-toggle-btn { + display: none; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + color: var(--text-normal); + background: transparent; + margin-right: 8px; +} + +.mobile-toggle-btn:hover { + background: var(--bg-modifier-hover); + border-radius: var(--radius-sm); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Responsive */ +/* ═══════════════════════════════════════════════════════════ */ +@media (max-width: 1100px) { + .shell { + grid-template-columns: 72px 248px 1fr; + } + + .utility-sidebar { + position: fixed; + top: 0; + bottom: 0; + right: 0; + width: 248px; + z-index: 100; + box-shadow: -2px 0 20px rgba(0, 0, 0, 0.5); + transform: translateX(100%); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + + .utility-sidebar.active { + transform: translateX(0); + display: flex; + } + + .chat-header .mobile-toggle-btn { + display: flex; + } +} + +@media (max-width: 768px) { + .shell { + grid-template-columns: 1fr; + } + + .server-rail, + .channel-sidebar { + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + + .server-rail { + left: 0; + width: 72px; + transform: translateX(-100%); + } + + .channel-sidebar { + left: 72px; + width: 248px; + transform: translateX(-320px); + box-shadow: 2px 0 20px rgba(0, 0, 0, 0.5); + } + + .shell.menu-open .server-rail { + transform: translateX(0); + } + + .shell.menu-open .channel-sidebar { + transform: translateX(0); + display: flex; + } + + .chat-header { + padding: 0 8px; + } +} \ No newline at end of file diff --git a/main.js b/main.js index 030c413..3a32c94 100644 --- a/main.js +++ b/main.js @@ -1,11 +1,11 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); -const packageJson = require('./package.json'); -const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000; +const fs = require('fs'); +const url = require('url'); const updateState = { - status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error + status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error info: null, error: null, }; @@ -19,43 +19,41 @@ function broadcastUpdateState() { } } +function getStorageFilePath() { + return path.join(app.getPath('userData'), 'renderer-storage.json'); +} + +function readPersistentStore() { + const filePath = getStorageFilePath(); + try { + if (!fs.existsSync(filePath)) return {}; + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (err) { + console.error('Failed to read persistent store', err); + return {}; + } +} + +function writePersistentStore(store) { + const filePath = getStorageFilePath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); + } catch (err) { + console.error('Failed to write persistent store', err); + } +} + function isNewerVersionAvailable(info) { const next = info && typeof info.version === 'string' ? info.version : ''; return Boolean(next) && next !== app.getVersion(); } let updateCheckInProgress = false; -let installInProgress = false; -let periodicUpdateTimer = null; - -function resolveBackendUrl() { - const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim(); - if (configuredUrl) { - return configuredUrl.replace(/\/$/, ''); - } - - const packagedFallback = typeof packageJson.homepage === 'string' - ? packageJson.homepage.trim() - : ''; - if (app.isPackaged && packagedFallback) { - return packagedFallback.replace(/\/$/, ''); - } - - return 'http://localhost:3000'; -} - -function webContentsOrigin(webContents) { - if (!webContents || typeof webContents.getURL !== 'function') { - return null; - } - return safeOrigin(webContents.getURL()); -} async function runUpdateCheck(reason = 'manual') { - if (installInProgress) { - console.log(`Update check skipped (${reason}): install already in progress`); - return; - } if (updateCheckInProgress) { console.log(`Update check skipped (${reason}): another check is already in progress`); return; @@ -85,62 +83,9 @@ async function runUpdateCheck(reason = 'manual') { } } -function installDownloadedUpdate() { - if (installInProgress) { - console.log('Update install already in progress'); - return; - } - - installInProgress = true; - updateState.status = 'installing'; - updateState.error = null; - broadcastUpdateState(); - - const wins = BrowserWindow.getAllWindows(); - for (const win of wins) { - if (win.isDestroyed()) continue; - try { - win.removeAllListeners('close'); - win.destroy(); - } catch (err) { - console.warn('Failed to destroy window before update install', err); - } - } - - setImmediate(() => { - try { - autoUpdater.quitAndInstall(false, true); - } catch (err) { - installInProgress = false; - updateState.status = 'error'; - updateState.error = err && err.message ? err.message : String(err); - broadcastUpdateState(); - console.error('quitAndInstall failed', err); - } - }); - - setTimeout(() => { - if (installInProgress) { - console.warn('Update install is still waiting for app shutdown'); - } - }, 15000); -} - -function startPeriodicUpdateChecks() { - if (periodicUpdateTimer || !app.isPackaged) { - return; - } - - periodicUpdateTimer = setInterval(() => { - void runUpdateCheck('periodic'); - }, PERIODIC_UPDATE_CHECK_INTERVAL_MS); -} - function createWindow() { + // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); - const backendUrl = resolveBackendUrl(); - const backendOrigin = new URL(backendUrl).origin; - console.log(`Desktop backend URL: ${backendUrl}`); const win = new BrowserWindow({ width: 1200, @@ -149,6 +94,7 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, + webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies session: sess, preload: path.join(__dirname, 'desktop', 'preload.js') } @@ -157,33 +103,32 @@ function createWindow() { win.setAutoHideMenuBar(true); win.setMenuBarVisibility(true); + // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { - const origin = webContentsOrigin(webContents); - if (origin !== backendOrigin) return false; - return permission === 'media' || permission === 'clipboard-write'; + if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { + return true; + } + return false; }); sess.setPermissionRequestHandler((webContents, permission, callback) => { - const origin = webContentsOrigin(webContents); - if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) { + if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { callback(true); } else { callback(false); } }); + // Handle screen share requests sess.setDisplayMediaRequestHandler((request, callback) => { - const origin = safeOrigin(request.frame?.url || win.webContents.getURL()); - if (origin !== backendOrigin) { - callback(null); - return; - } desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { + // Provide the first screen source by default, or implement a picker window here if (sources && sources.length > 0) { + // We prefer a screen over a window if available, simple heuristic const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0]; callback({ video: screenSource, audio: 'loopback' }); } else { - callback(null); + callback(null); // Reject safely } }).catch(err => { console.error("Failed to get desktop sources for screen share", err); @@ -191,9 +136,55 @@ function createWindow() { }); }); - win.loadURL(backendUrl).catch((err) => { - console.error(`Failed to load desktop app: ${err}`); - }); + const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, ''); + const indexPath = path.join(__dirname, 'desktop', 'index.html'); + + const loadDesktopApp = (queryParams = '') => { + const options = {}; + if (queryParams) { + try { + options.query = Object.fromEntries(new URLSearchParams(queryParams)); + } catch (e) { + console.error("Failed to parse query params", e); + } + } + + // Use setImmediate to ensure the current navigation tick is cleared, + // which prevents ERR_ABORTED (-3) on some platforms when interrupting a redirect. + setImmediate(() => { + if (win.isDestroyed()) return; + win.loadFile(indexPath, options).catch((err) => { + // Ignore aborted errors as they often happen during fast redirects + if (err.toString().includes('-3') || err.code === -3) return; + console.error(`Failed to load desktop file: ${err}`); + }); + }); + }; + + // Intercept navigations/redirects that return to the backend with a token + // after the OAuth login flow. Two handlers are needed because: + // - will-navigate fires for client-side navigations (location.href, link clicks) + // - will-redirect fires for server-side 302 redirects (OAuth callback chain) + const interceptLoginRedirect = (event, navigatedUrl) => { + try { + const urlObj = new URL(navigatedUrl); + const backendObj = new URL(backendUrl); + if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { + if (urlObj.searchParams.has('token')) { + console.log("Detected login success redirect, returning to desktop UI..."); + event.preventDefault(); + loadDesktopApp(urlObj.search.slice(1)); + } + } + } catch (e) { + console.error(e); + } + }; + + win.webContents.on('will-navigate', interceptLoginRedirect); + win.webContents.on('will-redirect', interceptLoginRedirect); + + loadDesktopApp(); // Ensure renderer receives latest updater state after any (re)load. // Delay broadcast by 300ms to give the renderer time to register its @@ -212,11 +203,40 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { + // Register IPC handlers once (before creating any windows) + ipcMain.handle('get-config', () => { + return { + backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') + }; + }); + ipcMain.handle('clipboard-write', (event, text) => { clipboard.writeText(text); return true; }); + ipcMain.handle('storage-get', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return null; + const store = readPersistentStore(); + return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; + }); + + ipcMain.handle('storage-set', (event, key, value) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + store[key] = value; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('storage-remove', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + delete store[key]; + writePersistentStore(store); + return true; + }); + ipcMain.handle('get-update-state', () => ({ ...updateState })); ipcMain.handle('check-for-updates-now', async () => { await runUpdateCheck('renderer-direct'); @@ -267,7 +287,6 @@ app.whenReady().then(() => { }); ipcMain.on('download-update', () => { - if (installInProgress) return; updateState.status = 'downloading'; updateState.error = null; broadcastUpdateState(); @@ -275,14 +294,13 @@ app.whenReady().then(() => { }); ipcMain.on('quit-and-install', () => { - installDownloadedUpdate(); + autoUpdater.quitAndInstall(); }); // Check once on startup setTimeout(() => { void runUpdateCheck('startup'); }, 5000); - startPeriodicUpdateChecks(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -296,18 +314,3 @@ app.on('window-all-closed', () => { app.quit(); } }); - -app.on('before-quit', () => { - if (periodicUpdateTimer) { - clearInterval(periodicUpdateTimer); - periodicUpdateTimer = null; - } -}); - -function safeOrigin(value) { - try { - return new URL(value).origin; - } catch (_) { - return null; - } -} diff --git a/package-lock.json b/package-lock.json index 686ca4b..f657203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chattz-electron", - "version": "0.0.50", + "version": "0.0.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chattz-electron", - "version": "0.0.50", + "version": "0.0.48", "dependencies": { "electron-updater": "^6.8.3" }, diff --git a/package.json b/package.json index f5c75b8..9949c75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chattz-electron", - "version": "0.0.50", + "version": "0.0.48", "description": "Electron frontend for Chattz", "author": "Pavel Flegr ", "homepage": "https://discord.flegr.me", @@ -10,20 +10,18 @@ }, "main": "main.js", "scripts": { - "build:html": "node scripts/generate-html.js", - "start": "npm run build:html && electron .", - "dev": "npm run build:html && electron .", - "dist": "npm run build:html && electron-builder --publish never", - "dist:linux": "npm run build:html && electron-builder --linux --publish never", - "dist:win": "npm run build:html && electron-builder --win --publish never" + "start": "electron .", + "dev": "electron .", + "dist": "electron-builder --publish never", + "dist:linux": "electron-builder --linux --publish never", + "dist:win": "electron-builder --win --publish never" }, "build": { "appId": "me.flegr.chattz", "productName": "Chattz", "linux": { "target": [ - "AppImage", - "rpm" + "AppImage" ], "category": "Chat", "icon": "build/icon.png" diff --git a/scripts/generate-html.js b/scripts/generate-html.js deleted file mode 100644 index a773454..0000000 --- a/scripts/generate-html.js +++ /dev/null @@ -1,57 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const root = path.resolve(__dirname, '..'); -const templatePath = path.join(root, 'shared-html', 'index.template.html'); -const template = fs.readFileSync(templatePath, 'utf8'); - -const updaterMarkup = ``; - -const variants = { - web: { - styles_href: '/static/styles.css', - lucide_src: '/static/vendor/lucide.min.js', - app_src: '/static/app.js?v=20260227-shared-core-1', - web_downloads: `
- Desktop downloads: - Windows - Linux (RPM) -
`, - desktop_updater: updaterMarkup, - outPath: path.join(root, 'static', 'index.html'), - }, - desktop: { - styles_href: '../static/styles.css', - lucide_src: '../static/vendor/lucide.min.js', - app_src: 'app.js?v=20260227-shared-core-1', - web_downloads: '', - desktop_updater: updaterMarkup, - outPath: path.join(root, 'desktop', 'index.html'), - }, -}; - -for (const variant of Object.values(variants)) { - let output = template; - for (const [key, value] of Object.entries(variant)) { - if (key === 'outPath') continue; - const token = `{{${key}}}`; - if (value === '') { - output = output.replace(new RegExp(`^[ \\t]*\\{\\{${key}\\}\\}[ \\t]*\\n?`, 'm'), ''); - continue; - } - output = output.replaceAll(token, value); - } - - if (/\{\{[a-z_]+\}\}/i.test(output)) { - throw new Error(`Unresolved template placeholders remain in ${variant.outPath}`); - } - - fs.writeFileSync( - variant.outPath, - `\n${output}`, - 'utf8' - ); -} diff --git a/shared-html/index.template.html b/shared-html/index.template.html deleted file mode 100644 index 3425611..0000000 --- a/shared-html/index.template.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - Chattz - - - - - - -
-
-
C
-

Chattz

-

Sign in to open your servers.

- - {{web_downloads}} -

-
-
- - - - - - - - - - - - - - - - - - - diff --git a/src/auth.rs b/src/auth.rs index 535953e..667c64d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,25 +1,33 @@ -use anyhow::{Result, anyhow}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; use axum::{ Json, extract::{FromRef, FromRequestParts}, - http::{HeaderMap, StatusCode, request::Parts}, + http::{StatusCode, request::Parts}, response::{IntoResponse, Response}, }; -use chrono::{Duration, Utc}; -use serde::Serialize; -use sha2::{Digest, Sha256}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{AppState, db}; -pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; -pub const SESSION_COOKIE: &str = "chattz_session"; -const SESSION_TTL_DAYS: i64 = 30; +const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; +const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes +const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SessionClaims { + pub sub: String, + pub kind: String, // "access" or "refresh" + pub exp: usize, + pub iat: usize, +} #[derive(Debug, Clone)] pub struct AuthUser { pub id: Uuid, - pub session_id: String, } #[derive(Debug)] @@ -36,13 +44,6 @@ impl ApiError { } } - pub fn forbidden(msg: &str) -> Self { - Self { - status: StatusCode::FORBIDDEN, - message: msg.to_string(), - } - } - pub fn bad_request(msg: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, @@ -56,13 +57,6 @@ impl ApiError { message: msg.to_string(), } } - - pub fn service_unavailable(msg: &str) -> Self { - Self { - status: StatusCode::SERVICE_UNAVAILABLE, - message: msg.to_string(), - } - } } #[derive(Serialize)] @@ -90,25 +84,29 @@ impl From for ApiError { impl FromRequestParts for AuthUser where - AppState: FromRef, + AppState: axum::extract::FromRef, S: Send + Sync, { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app = AppState::from_ref(state); - let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE) - .ok_or_else(|| ApiError::unauthorized("missing session cookie"))?; - let user_id = db::touch_active_session(&app.db, &session_id) + let token = read_bearer_token(parts) + .or_else(|| read_query_token(parts)) + .ok_or_else(|| ApiError::unauthorized("missing jwt token"))?; + + let user_id = verify_session(&token, &app.settings.session_secret, "access") + .map_err(|_| ApiError::unauthorized("invalid or expired token"))?; + + let exists = db::user_exists(&app.db, user_id) .await - .map_err(|_| ApiError::unauthorized("invalid or expired session"))? - .ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?; + .map_err(|_| ApiError::unauthorized("session user not found"))?; + if !exists { + return Err(ApiError::unauthorized("session user not found")); + } - Ok(Self { - id: user_id, - session_id, - }) + Ok(Self { id: user_id }) } } @@ -116,14 +114,6 @@ pub fn new_oauth_state() -> String { Uuid::new_v4().to_string() } -pub fn new_session_id() -> String { - Uuid::new_v4().simple().to_string() -} - -pub fn session_expiry() -> chrono::DateTime { - (Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset() -} - pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { format!( "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}", @@ -133,32 +123,76 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { } pub fn clear_oauth_state_cookie(secure: bool) -> String { - clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax") -} - -pub fn make_session_cookie(value: &str, secure: bool) -> String { format!( - "{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}", - name = SESSION_COOKIE, - ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(), + "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", + name = OAUTH_STATE_COOKIE, secure_flag = if secure { "; Secure" } else { "" } ) } -pub fn clear_session_cookie(secure: bool) -> String { - clear_cookie(SESSION_COOKIE, secure, "Strict") -} - -pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option { +pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option { let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; raw.split(';').find_map(|pair| { let mut kv = pair.trim().splitn(2, '='); let key = kv.next()?; let value = kv.next()?; - (key == cookie_name).then(|| value.to_string()) + (key == OAUTH_STATE_COOKIE).then(|| value.to_string()) }) } +pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> { + let now = now_ts(); + + let access_claims = SessionClaims { + sub: user_id.to_string(), + kind: "access".to_string(), + iat: now as usize, + exp: (now + SESSION_TTL_SECS) as usize, + }; + + let refresh_claims = SessionClaims { + sub: user_id.to_string(), + kind: "refresh".to_string(), + iat: now as usize, + exp: (now + REFRESH_TTL_SECS) as usize, + }; + + let access_token = encode( + &Header::default(), + &access_claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .context("failed to encode access token")?; + + let refresh_token = encode( + &Header::default(), + &refresh_claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .context("failed to encode refresh token")?; + + Ok((access_token, refresh_token)) +} + +pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result { + let mut validation = Validation::default(); + validation.validate_exp = true; + + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .context("failed to decode session token")?; + + if data.claims.kind != expected_kind { + return Err(anyhow!("invalid token kind")); + } + + let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?; + Ok(user_id) +} + pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) -> Result<()> { let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?; if expected != query_state { @@ -167,113 +201,37 @@ pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) Ok(()) } -pub fn user_agent_hash(headers: &HeaderMap) -> Option { - header_hash(headers, axum::http::header::USER_AGENT.as_str()) -} - -pub fn ip_hash(headers: &HeaderMap) -> Option { - headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|raw| raw.split(',').next().map(str::trim)) - .filter(|value| !value.is_empty()) - .map(hash_string) - .or_else(|| header_hash(headers, "x-real-ip")) -} - -pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool { - headers - .get(axum::http::header::ORIGIN) - .and_then(|value| value.to_str().ok()) - .map(|origin| origin == expected_origin) - .unwrap_or(false) -} - -fn header_hash(headers: &HeaderMap, header_name: &str) -> Option { - headers - .get(header_name) - .and_then(|v| v.to_str().ok()) - .filter(|value| !value.trim().is_empty()) - .map(hash_string) -} - -fn hash_string(value: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(value.as_bytes()); - let digest = hasher.finalize(); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - out.push(nibble_to_hex(byte >> 4)); - out.push(nibble_to_hex(byte & 0x0f)); - } - out -} - -fn nibble_to_hex(value: u8) -> char { - match value { - 0..=9 => (b'0' + value) as char, - 10..=15 => (b'a' + (value - 10)) as char, - _ => unreachable!(), +fn read_bearer_token(parts: &Parts) -> Option { + let raw = parts + .headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()?; + if raw.starts_with("Bearer ") { + Some(raw["Bearer ".len()..].trim().to_string()) + } else { + None } } -fn clear_cookie(name: &str, secure: bool, same_site: &str) -> String { - format!( - "{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}", - secure_flag = if secure { "; Secure" } else { "" } - ) +fn read_query_token(parts: &Parts) -> Option { + let query = parts.uri.query()?; + + // Simple query param parsing without pulling in url::Url overhead + for pair in query.split('&') { + let mut kv = pair.splitn(2, '='); + let key = kv.next()?; + let value = kv.next()?; + if key == "token" { + return Some(value.to_string()); + } + } + None } -#[cfg(test)] -mod tests { - use super::{ - SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches, - read_cookie_from_headers, - }; - use axum::http::{HeaderMap, header}; - - #[test] - fn hash_string_is_stable() { - assert_eq!( - hash_string("example"), - "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c" - ); - } - - #[test] - fn reads_named_cookie_from_header() { - let mut headers = HeaderMap::new(); - headers.insert( - header::COOKIE, - "other=value; chattz_session=session-123; another=ok" - .parse() - .unwrap(), - ); - - assert_eq!( - read_cookie_from_headers(&headers, SESSION_COOKIE), - Some("session-123".to_string()) - ); - } - - #[test] - fn origin_match_requires_exact_origin() { - let mut headers = HeaderMap::new(); - headers.insert(header::ORIGIN, "http://localhost:3000".parse().unwrap()); - - assert!(origin_matches(&headers, "http://localhost:3000")); - assert!(!origin_matches(&headers, "https://localhost:3000")); - } - - #[test] - fn session_cookie_has_strict_policy_and_clear_cookie_expires() { - let session_cookie = make_session_cookie("session-123", false); - let cleared_cookie = clear_session_cookie(false); - - assert!(session_cookie.contains("HttpOnly")); - assert!(session_cookie.contains("SameSite=Strict")); - assert!(session_cookie.contains("Max-Age=")); - assert!(cleared_cookie.contains("SameSite=Strict")); - assert!(cleared_cookie.contains("Max-Age=0")); - } +fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() } diff --git a/src/chat.rs b/src/chat.rs index 565f08b..8b59bf6 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,30 +1,27 @@ -use std::collections::HashMap; - +use crate::AppState; use axum::extract::ws::{Message, WebSocket}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use tokio::sync::{RwLock, mpsc}; use uuid::Uuid; -use crate::{AppState, db}; - #[derive(Clone)] pub struct ChatClient { tx: mpsc::UnboundedSender, is_idle: bool, } -#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Clone)] pub struct OnlineUser { pub user_id: Uuid, - pub online: bool, pub idle: bool, } #[derive(Default)] pub struct ChatHub { - // user_id -> connection_id -> client - clients: RwLock>>, + // user_id -> client + clients: RwLock>, } #[derive(Serialize, Clone)] @@ -48,113 +45,85 @@ pub enum ServerEvent { #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientEvent { + // Currently no interactive client events for the general chat WS Ping, SetIdleStatus { is_idle: bool }, } impl ChatHub { - pub async fn add_client( - &self, - user_id: Uuid, - connection_id: Uuid, - tx: mpsc::UnboundedSender, - ) -> Option { + pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender) { let mut clients = self.clients.write().await; - let previous = aggregate_presence(clients.get(&user_id), user_id); - - clients - .entry(user_id) - .or_default() - .insert(connection_id, ChatClient { tx, is_idle: false }); - - let current = aggregate_presence(clients.get(&user_id), user_id); - presence_delta(previous, current) + clients.insert(user_id, ChatClient { tx, is_idle: false }); } - pub async fn remove_client(&self, user_id: Uuid, connection_id: Uuid) -> Option { + pub async fn remove_client(&self, user_id: Uuid) { let mut clients = self.clients.write().await; - let previous = aggregate_presence(clients.get(&user_id), user_id); - - if let Some(connections) = clients.get_mut(&user_id) { - connections.remove(&connection_id); - if connections.is_empty() { - clients.remove(&user_id); - } - } - - let current = aggregate_presence(clients.get(&user_id), user_id); - presence_delta(previous, current) + clients.remove(&user_id); } - pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec { + pub async fn get_online_users(&self) -> Vec { let clients = self.clients.read().await; - visible_user_ids + clients .iter() - .filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id)) - .filter(|presence| presence.online) + .map(|(id, client)| OnlineUser { + user_id: *id, + idle: client.is_idle, + }) .collect() } - pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) { + pub async fn broadcast_all(&self, event: ServerEvent) { let clients = self.clients.read().await; - for user_id in user_ids { - if let Some(connections) = clients.get(&user_id) { - for client in connections.values() { - let _ = client.tx.send(event.clone()); - } - } + for client in clients.values() { + let _ = client.tx.send(event.clone()); } } pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { let clients = self.clients.read().await; - if let Some(connections) = clients.get(&user_id) { - for client in connections.values() { + if let Some(client) = clients.get(&user_id) { + let _ = client.tx.send(event); + } + } + + pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) { + let clients = self.clients.read().await; + for user_id in user_ids { + if let Some(client) = clients.get(&user_id) { let _ = client.tx.send(event.clone()); } } } - pub async fn set_idle_status( - &self, - user_id: Uuid, - connection_id: Uuid, - is_idle: bool, - ) -> Option { - let mut clients = self.clients.write().await; - let previous = aggregate_presence(clients.get(&user_id), user_id); - - if let Some(connections) = clients.get_mut(&user_id) - && let Some(client) = connections.get_mut(&connection_id) + pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) { { - client.is_idle = is_idle; + let mut clients = self.clients.write().await; + if let Some(client) = clients.get_mut(&user_id) { + client.is_idle = is_idle; + } } - - let current = aggregate_presence(clients.get(&user_id), user_id); - presence_delta(previous, current) + self.broadcast_all(ServerEvent::UserPresence { + user_id, + online: true, + idle: is_idle, + }) + .await; } } pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { let (mut ws_sender, mut ws_receiver) = socket.split(); let (tx, mut rx) = mpsc::unbounded_channel::(); - let connection_id = Uuid::new_v4(); - if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await { - if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await { - state - .chat - .broadcast_to_many( - visible_user_ids, - ServerEvent::UserPresence { - user_id: presence.user_id, - online: presence.online, - idle: presence.idle, - }, - ) - .await; - } - } + state.chat.add_client(user_id, tx).await; + state + .chat + .broadcast_all(ServerEvent::UserPresence { + user_id, + online: true, + idle: false, + }) + .await; let send_task = tokio::spawn(async move { while let Some(event) = rx.recv().await { @@ -173,24 +142,8 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { Message::Text(text) => { if let Ok(ClientEvent::SetIdleStatus { is_idle }) = serde_json::from_str::(&text) - && let Some(presence) = state - .chat - .set_idle_status(user_id, connection_id, is_idle) - .await - && let Ok(visible_user_ids) = - db::list_visible_user_ids(&state.db, user_id).await { - state - .chat - .broadcast_to_many( - visible_user_ids, - ServerEvent::UserPresence { - user_id: presence.user_id, - online: presence.online, - idle: presence.idle, - }, - ) - .await; + state.chat.set_idle_status(user_id, is_idle).await; } } _ => {} @@ -198,157 +151,13 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { } send_task.abort(); - if let Some(presence) = state.chat.remove_client(user_id, connection_id).await - && let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await - { - state - .chat - .broadcast_to_many( - visible_user_ids, - ServerEvent::UserPresence { - user_id: presence.user_id, - online: presence.online, - idle: presence.idle, - }, - ) - .await; - } -} - -fn aggregate_presence( - connections: Option<&HashMap>, - user_id: Uuid, -) -> Option { - let connections = connections?; - if connections.is_empty() { - return None; - } - - let idle = connections.values().all(|client| client.is_idle); - Some(OnlineUser { - user_id, - online: true, - idle, - }) -} - -fn presence_delta(previous: Option, current: Option) -> Option { - match (previous, current) { - (None, None) => None, - (Some(prev), Some(curr)) if prev == curr => None, - (Some(prev), None) => Some(OnlineUser { - user_id: prev.user_id, + state.chat.remove_client(user_id).await; + state + .chat + .broadcast_all(ServerEvent::UserPresence { + user_id, online: false, idle: false, - }), - (_, Some(curr)) => Some(curr), - } -} - -#[cfg(test)] -mod tests { - use super::{ChatHub, OnlineUser, ServerEvent}; - use serde_json::json; - use tokio::sync::mpsc; - use uuid::Uuid; - - #[tokio::test] - async fn multiple_connections_keep_user_online_until_last_disconnect() { - let hub = ChatHub::default(); - let user_id = Uuid::new_v4(); - let first_connection = Uuid::new_v4(); - let second_connection = Uuid::new_v4(); - let (tx1, _rx1) = mpsc::unbounded_channel(); - let (tx2, _rx2) = mpsc::unbounded_channel(); - - let first_presence = hub.add_client(user_id, first_connection, tx1).await; - let second_presence = hub.add_client(user_id, second_connection, tx2).await; - let after_first_disconnect = hub.remove_client(user_id, first_connection).await; - let after_last_disconnect = hub.remove_client(user_id, second_connection).await; - - assert_eq!( - first_presence, - Some(OnlineUser { - user_id, - online: true, - idle: false, - }) - ); - assert_eq!(second_presence, None); - assert_eq!(after_first_disconnect, None); - assert_eq!( - after_last_disconnect, - Some(OnlineUser { - user_id, - online: false, - idle: false, - }) - ); - } - - #[tokio::test] - async fn idle_status_only_flips_when_all_connections_are_idle() { - let hub = ChatHub::default(); - let user_id = Uuid::new_v4(); - let first_connection = Uuid::new_v4(); - let second_connection = Uuid::new_v4(); - let (tx1, _rx1) = mpsc::unbounded_channel(); - let (tx2, _rx2) = mpsc::unbounded_channel(); - - hub.add_client(user_id, first_connection, tx1).await; - hub.add_client(user_id, second_connection, tx2).await; - - let first_idle = hub.set_idle_status(user_id, first_connection, true).await; - let second_idle = hub.set_idle_status(user_id, second_connection, true).await; - let active_again = hub.set_idle_status(user_id, first_connection, false).await; - - assert_eq!(first_idle, None); - assert_eq!( - second_idle, - Some(OnlineUser { - user_id, - online: true, - idle: true, - }) - ); - assert_eq!( - active_again, - Some(OnlineUser { - user_id, - online: true, - idle: false, - }) - ); - } - - #[tokio::test] - async fn broadcast_to_user_reaches_all_active_connections() { - let hub = ChatHub::default(); - let user_id = Uuid::new_v4(); - let first_connection = Uuid::new_v4(); - let second_connection = Uuid::new_v4(); - let (tx1, mut rx1) = mpsc::unbounded_channel(); - let (tx2, mut rx2) = mpsc::unbounded_channel(); - - hub.add_client(user_id, first_connection, tx1).await; - hub.add_client(user_id, second_connection, tx2).await; - - hub.broadcast_to_user( - user_id, - ServerEvent::DmCreated { - other_user_id: Uuid::new_v4(), - message: json!({ "body": "hello" }), - }, - ) + }) .await; - - assert!(matches!( - rx1.try_recv(), - Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello" - )); - assert!(matches!( - rx2.try_recv(), - Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello" - )); - } } diff --git a/src/config.rs b/src/config.rs index fbe7ecb..0696d55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,8 @@ -use anyhow::{Context, Result, anyhow}; -use reqwest::Url; +use anyhow::{Context, Result}; #[derive(Clone, Debug)] pub struct Settings { pub port: u16, - pub app_base_url: String, - pub app_origin: String, pub database_url: String, pub oidc_client_id: String, pub oidc_client_secret: String, @@ -14,36 +11,21 @@ pub struct Settings { pub oidc_userinfo_url: String, pub oidc_redirect_url: String, pub oidc_scopes: String, - pub session_cookie_secure: bool, + pub session_secret: String, + pub cookie_secure: bool, pub stun_urls: Vec, pub turn_urls: Vec, pub turn_username: Option, pub turn_password: Option, - pub media: Option, -} - -#[derive(Clone, Debug)] -pub struct MediaSettings { - pub account_id: String, - pub access_key_id: String, - pub secret_access_key: String, - pub bucket: String, - pub public_base_url: String, } impl Settings { pub fn from_env() -> Result { - let app_base_url = required("APP_BASE_URL")?; - let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?; - let app_origin = origin_from_url(&app_url)?; - Ok(Self { port: std::env::var("PORT") .unwrap_or_else(|_| "3000".into()) .parse() .context("PORT must be a valid u16")?, - app_base_url: trim_trailing_slash(&app_base_url), - app_origin, database_url: required("DATABASE_URL")?, oidc_client_id: required("OIDC_CLIENT_ID")?, oidc_client_secret: required("OIDC_CLIENT_SECRET")?, @@ -51,51 +33,20 @@ impl Settings { oidc_token_url: required("OIDC_TOKEN_URL")?, oidc_userinfo_url: required("OIDC_USERINFO_URL")?, oidc_redirect_url: required("OIDC_REDIRECT_URL")?, - oidc_scopes: std::env::var("OIDC_SCOPES") - .unwrap_or_else(|_| "openid profile email".to_string()), - session_cookie_secure: requires_secure_cookie(&app_url)?, + oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()), + session_secret: required("SESSION_SECRET")?, + cookie_secure: std::env::var("COOKIE_SECURE") + .unwrap_or_else(|_| "false".into()) + .parse() + .context("COOKIE_SECURE must be true/false")?, stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"), turn_urls: parse_csv_env("TURN_URLS", ""), turn_username: optional("TURN_USERNAME"), turn_password: optional("TURN_PASSWORD"), - media: MediaSettings::from_env()?, }) } } -impl MediaSettings { - fn from_env() -> Result> { - let account_id = optional("R2_ACCOUNT_ID"); - let access_key_id = optional("R2_ACCESS_KEY_ID"); - let secret_access_key = optional("R2_SECRET_ACCESS_KEY"); - let bucket = optional("R2_BUCKET"); - let public_base_url = optional("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL")); - - if account_id.is_none() - && access_key_id.is_none() - && secret_access_key.is_none() - && bucket.is_none() - && public_base_url.is_none() - { - return Ok(None); - } - - Ok(Some(Self { - account_id: account_id.context("missing env var R2_ACCOUNT_ID")?, - access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?, - secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?, - bucket: bucket.context("missing env var R2_BUCKET")?, - public_base_url: trim_trailing_slash( - &public_base_url.context("missing env var MEDIA_BASE_URL")?, - ), - })) - } - - pub fn endpoint_url(&self) -> String { - format!("https://{}.r2.cloudflarestorage.com", self.account_id) - } -} - fn required(name: &str) -> Result { std::env::var(name).with_context(|| format!("missing env var {name}")) } @@ -115,105 +66,3 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec { .map(ToString::to_string) .collect() } - -fn origin_from_url(url: &Url) -> Result { - let host = url - .host_str() - .ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?; - let mut origin = format!("{}://{}", url.scheme(), host); - if let Some(port) = url.port() { - origin.push(':'); - origin.push_str(&port.to_string()); - } - Ok(origin) -} - -fn requires_secure_cookie(url: &Url) -> Result { - match url.scheme() { - "https" => Ok(true), - "http" => { - let host = url - .host_str() - .ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?; - if matches!(host, "localhost" | "127.0.0.1" | "::1") { - Ok(false) - } else { - Err(anyhow!( - "APP_BASE_URL must use https outside localhost when session cookies are enabled" - )) - } - } - other => Err(anyhow!("APP_BASE_URL scheme {other} is not supported")), - } -} - -fn trim_trailing_slash(value: &str) -> String { - value.trim_end_matches('/').to_string() -} - -#[cfg(test)] -mod tests { - use super::{MediaSettings, origin_from_url, requires_secure_cookie, trim_trailing_slash}; - use reqwest::Url; - - #[test] - fn origin_from_url_preserves_explicit_port() { - let url = Url::parse("https://chat.example.com:8443/app").unwrap(); - let origin = origin_from_url(&url).unwrap(); - - assert_eq!(origin, "https://chat.example.com:8443"); - } - - #[test] - fn secure_cookie_is_required_for_https_origins() { - let url = Url::parse("https://chat.example.com").unwrap(); - - assert_eq!(requires_secure_cookie(&url).unwrap(), true); - } - - #[test] - fn localhost_http_is_allowed_without_secure_cookie() { - let url = Url::parse("http://localhost:3000").unwrap(); - - assert_eq!(requires_secure_cookie(&url).unwrap(), false); - } - - #[test] - fn non_localhost_http_is_rejected() { - let url = Url::parse("http://chat.example.com").unwrap(); - let err = requires_secure_cookie(&url).unwrap_err(); - - assert!( - err.to_string() - .contains("APP_BASE_URL must use https outside localhost") - ); - } - - #[test] - fn trim_trailing_slash_removes_only_suffix_slashes() { - assert_eq!( - trim_trailing_slash("https://chat.example.com///"), - "https://chat.example.com" - ); - assert_eq!( - trim_trailing_slash("https://chat.example.com/app"), - "https://chat.example.com/app" - ); - } - - #[test] - fn media_endpoint_url_uses_account_id() { - let media = MediaSettings { - account_id: "acct123".to_string(), - access_key_id: "key".to_string(), - secret_access_key: "secret".to_string(), - bucket: "bucket".to_string(), - public_base_url: "https://media.example.com".to_string(), - }; - - assert_eq!( - media.endpoint_url(), - "https://acct123.r2.cloudflarestorage.com" - ); - } -} diff --git a/src/db.rs b/src/db.rs index c86dd8c..d4364bc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,22 +1,20 @@ -use std::collections::HashSet; - use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - Statement, TransactionTrait, sea_query::OnConflict, + TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{ - attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions, - soundboard_sounds, users, + channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds, + users, }, models::{ - Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, - MessageWithAuthor, SoundboardSound, User, + BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, + SoundboardSound, User, }, }; @@ -28,88 +26,6 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result Ok(count > 0) } -pub async fn create_session( - db: &DatabaseConnection, - session_id: &str, - user_id: Uuid, - expires_at: chrono::DateTime, - user_agent_hash: Option, - ip_hash: Option, -) -> Result<()> { - sessions::Entity::insert(sessions::ActiveModel { - id: Set(session_id.to_string()), - user_id: Set(user_id), - expires_at: Set(expires_at), - created_at: Set(Utc::now().fixed_offset()), - last_seen_at: Set(Utc::now().fixed_offset()), - revoked_at: Set(None), - user_agent_hash: Set(user_agent_hash), - ip_hash: Set(ip_hash), - }) - .exec(db) - .await?; - - Ok(()) -} - -pub async fn touch_active_session( - db: &DatabaseConnection, - session_id: &str, -) -> Result> { - let session = sessions::Entity::find_by_id(session_id.to_string()) - .one(db) - .await?; - - let Some(session) = session else { - return Ok(None); - }; - - if session.revoked_at.is_some() || session.expires_at <= Utc::now().fixed_offset() { - return Ok(None); - } - - let user_id = session.user_id; - sessions::Entity::update_many() - .col_expr( - sessions::Column::LastSeenAt, - sea_orm::sea_query::Expr::value(Utc::now().fixed_offset()), - ) - .filter(sessions::Column::Id.eq(session_id.to_string())) - .exec(db) - .await?; - Ok(Some(user_id)) -} - -pub async fn revoke_session(db: &DatabaseConnection, session_id: &str) -> Result<()> { - if let Some(session) = sessions::Entity::find_by_id(session_id.to_string()) - .one(db) - .await? - { - sessions::Entity::update_many() - .col_expr( - sessions::Column::RevokedAt, - sea_orm::sea_query::Expr::value(Some(Utc::now().fixed_offset())), - ) - .filter(sessions::Column::Id.eq(session.id)) - .exec(db) - .await?; - } - - Ok(()) -} - -pub async fn cleanup_sessions(db: &DatabaseConnection) -> Result<()> { - sessions::Entity::delete_many() - .filter( - Condition::any() - .add(sessions::Column::ExpiresAt.lte(Utc::now().fixed_offset())) - .add(sessions::Column::RevokedAt.is_not_null()), - ) - .exec(db) - .await?; - Ok(()) -} - pub async fn upsert_user_from_oidc( db: &DatabaseConnection, oidc_sub: &str, @@ -190,58 +106,18 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res .collect()) } -pub async fn list_visible_user_ids(db: &DatabaseConnection, user_id: Uuid) -> Result> { - let guild_ids: Vec = guild_members::Entity::find() - .filter(guild_members::Column::UserId.eq(user_id)) - .select_only() - .column(guild_members::Column::GuildId) - .into_tuple() - .all(db) - .await?; - - let mut visible = HashSet::from([user_id]); - - if !guild_ids.is_empty() { - let guild_users = guild_members::Entity::find() - .filter(guild_members::Column::GuildId.is_in(guild_ids)) - .all(db) - .await?; - visible.extend(guild_users.into_iter().map(|membership| membership.user_id)); - } - - let dm_rows = direct_messages::Entity::find() - .filter( - Condition::any() - .add(direct_messages::Column::SenderUserId.eq(user_id)) - .add(direct_messages::Column::RecipientUserId.eq(user_id)), - ) - .all(db) - .await?; - - for row in dm_rows { - if row.sender_user_id == user_id { - visible.insert(row.recipient_user_id); - } else { - visible.insert(row.sender_user_id); - } - } - - Ok(visible.into_iter().collect()) -} - pub async fn create_guild( db: &DatabaseConnection, owner_user_id: Uuid, name: &str, ) -> Result { - let txn = db.begin().await?; let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), owner_user_id: Set(owner_user_id), ..Default::default() }) - .exec_with_returning(&txn) + .exec_with_returning(db) .await?; guild_members::Entity::insert(guild_members::ActiveModel { @@ -257,13 +133,17 @@ pub async fn create_guild( .do_nothing() .to_owned(), ) - .exec(&txn) + .exec(db) .await?; - txn.commit().await?; Ok(map_guild(guild)) } +pub async fn get_guild_by_id(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let row = guilds::Entity::find_by_id(guild_id).one(db).await?; + Ok(row.map(map_guild)) +} + pub async fn is_guild_owner( db: &DatabaseConnection, guild_id: Uuid, @@ -317,12 +197,7 @@ pub async fn create_invite( pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result { let txn = db.begin().await?; - let invite = invites::Entity::find() - .from_raw_sql(Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#, - [code.into()], - )) + let invite = invites::Entity::find_by_id(code.to_string()) .one(&txn) .await? .ok_or_else(|| anyhow!("invite not found"))?; @@ -475,63 +350,6 @@ pub async fn create_message( author_user_id: model.author_user_id, author_display_name: user.display_name, body: model.body, - attachments: Vec::new(), - created_at: model.created_at, - }) -} - -pub async fn create_message_with_attachment( - db: &DatabaseConnection, - channel_id: Uuid, - author_user_id: Uuid, - body: &str, - object_key: &str, - media_url: &str, - mime_type: &str, - size_bytes: i64, - original_filename: &str, -) -> Result { - let txn = db.begin().await?; - - let model = messages::Entity::insert(messages::ActiveModel { - id: Set(Uuid::new_v4()), - channel_id: Set(channel_id), - author_user_id: Set(author_user_id), - body: Set(body.to_string()), - ..Default::default() - }) - .exec_with_returning(&txn) - .await?; - - let attachment = attachments::Entity::insert(attachments::ActiveModel { - id: Set(Uuid::new_v4()), - channel_message_id: Set(Some(model.id)), - direct_message_id: Set(None), - uploader_user_id: Set(author_user_id), - object_key: Set(object_key.to_string()), - media_url: Set(media_url.to_string()), - mime_type: Set(mime_type.to_string()), - size_bytes: Set(size_bytes), - original_filename: Set(original_filename.to_string()), - ..Default::default() - }) - .exec_with_returning(&txn) - .await?; - - let user = users::Entity::find_by_id(author_user_id) - .one(&txn) - .await? - .ok_or_else(|| anyhow!("author not found"))?; - - txn.commit().await?; - - Ok(MessageWithAuthor { - id: model.id, - channel_id: model.channel_id, - author_user_id: model.author_user_id, - author_display_name: user.display_name, - body: model.body, - attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -549,10 +367,6 @@ pub async fn list_messages( .all(db) .await?; - let attachment_map = - list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect()) - .await?; - Ok(rows .into_iter() .map(|(msg, user)| { @@ -565,7 +379,6 @@ pub async fn list_messages( author_user_id: msg.author_user_id, author_display_name, body: msg.body, - attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, } }) @@ -599,63 +412,6 @@ pub async fn create_direct_message( recipient_user_id: model.recipient_user_id, author_display_name: user.display_name, body: model.body, - attachments: Vec::new(), - created_at: model.created_at, - }) -} - -pub async fn create_direct_message_with_attachment( - db: &DatabaseConnection, - sender_user_id: Uuid, - recipient_user_id: Uuid, - body: &str, - object_key: &str, - media_url: &str, - mime_type: &str, - size_bytes: i64, - original_filename: &str, -) -> Result { - let txn = db.begin().await?; - - let model = direct_messages::Entity::insert(direct_messages::ActiveModel { - id: Set(Uuid::new_v4()), - sender_user_id: Set(sender_user_id), - recipient_user_id: Set(recipient_user_id), - body: Set(body.to_string()), - ..Default::default() - }) - .exec_with_returning(&txn) - .await?; - - let attachment = attachments::Entity::insert(attachments::ActiveModel { - id: Set(Uuid::new_v4()), - channel_message_id: Set(None), - direct_message_id: Set(Some(model.id)), - uploader_user_id: Set(sender_user_id), - object_key: Set(object_key.to_string()), - media_url: Set(media_url.to_string()), - mime_type: Set(mime_type.to_string()), - size_bytes: Set(size_bytes), - original_filename: Set(original_filename.to_string()), - ..Default::default() - }) - .exec_with_returning(&txn) - .await?; - - let user = users::Entity::find_by_id(sender_user_id) - .one(&txn) - .await? - .ok_or_else(|| anyhow!("sender not found"))?; - - txn.commit().await?; - - Ok(DmMessageWithAuthor { - id: model.id, - author_user_id: model.sender_user_id, - recipient_user_id: model.recipient_user_id, - author_display_name: user.display_name, - body: model.body, - attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -698,9 +454,6 @@ pub async fn list_direct_messages( .map(|u| (u.id, u.display_name)) .collect(); - let attachment_map = - list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?; - Ok(rows .into_iter() .map(|msg| DmMessageWithAuthor { @@ -712,64 +465,11 @@ pub async fn list_direct_messages( .cloned() .unwrap_or_else(|| "Unknown User".to_string()), body: msg.body, - attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, }) .collect()) } -async fn list_attachments_for_channel_messages( - db: &DatabaseConnection, - message_ids: Vec, -) -> Result>> { - if message_ids.is_empty() { - return Ok(std::collections::HashMap::new()); - } - - let rows = attachments::Entity::find() - .filter(attachments::Column::ChannelMessageId.is_in(message_ids)) - .order_by_asc(attachments::Column::CreatedAt) - .all(db) - .await?; - - let mut grouped = std::collections::HashMap::>::new(); - for row in rows { - if let Some(message_id) = row.channel_message_id { - grouped - .entry(message_id) - .or_default() - .push(map_attachment(row)); - } - } - Ok(grouped) -} - -async fn list_attachments_for_direct_messages( - db: &DatabaseConnection, - message_ids: Vec, -) -> Result>> { - if message_ids.is_empty() { - return Ok(std::collections::HashMap::new()); - } - - let rows = attachments::Entity::find() - .filter(attachments::Column::DirectMessageId.is_in(message_ids)) - .order_by_asc(attachments::Column::CreatedAt) - .all(db) - .await?; - - let mut grouped = std::collections::HashMap::>::new(); - for row in rows { - if let Some(message_id) = row.direct_message_id { - grouped - .entry(message_id) - .or_default() - .push(map_attachment(row)); - } - } - Ok(grouped) -} - pub async fn list_dm_conversations( db: &DatabaseConnection, current_user_id: Uuid, @@ -843,16 +543,6 @@ fn map_guild(model: guilds::Model) -> Guild { } } -fn map_attachment(model: attachments::Model) -> Attachment { - Attachment { - id: model.id, - media_url: model.media_url, - mime_type: model.mime_type, - size_bytes: model.size_bytes, - original_filename: model.original_filename, - } -} - fn map_channel(model: channels::Model) -> Channel { Channel { id: model.id, @@ -922,10 +612,7 @@ pub async fn create_sound( created_by_user_id: Uuid, name: &str, icon: &str, - object_key: &str, - media_url: &str, - mime_type: &str, - size_bytes: i64, + file_path: &str, ) -> Result { let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel { id: Set(Uuid::new_v4()), @@ -933,13 +620,7 @@ pub async fn create_sound( created_by_user_id: Set(created_by_user_id), name: Set(name.to_string()), icon: Set(icon.to_string()), - object_key: Set(Some(object_key.to_string())), - media_url: Set(media_url.to_string()), - mime_type: Set(Some(mime_type.to_string())), - size_bytes: Set(Some(size_bytes)), - // Keep the legacy column populated until every deployment has applied - // the nullable migration and old fallback paths are fully removed. - file_path: Set(Some(media_url.to_string())), + file_path: Set(file_path.to_string()), ..Default::default() }) .exec_with_returning(db) @@ -953,11 +634,6 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result