From e1dd679c47b7fc890c126d69fb11e9177405d57c Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 15:38:46 +0100 Subject: [PATCH] feat: media uploads --- .env.example | 7 + Cargo.lock | 815 +++++++++++++++++- Cargo.toml | 2 + src/config.rs | 45 +- src/db.rs | 193 ++++- src/entity/attachments.rs | 66 ++ src/entity/mod.rs | 1 + src/handlers.rs | 261 +++++- src/main.rs | 12 +- src/media.rs | 74 ++ src/migration/m20260213_000001_init.rs | 29 +- src/migration/m20260213_000002_invites.rs | 7 +- .../m20260213_000004_direct_messages.rs | 12 +- src/migration/m20260227_000006_attachments.rs | 122 +++ src/migration/mod.rs | 2 + src/models.rs | 13 +- static/index.html | 4 + static/shared/app-core.js | 124 ++- static/styles.css | 91 +- 19 files changed, 1807 insertions(+), 73 deletions(-) create mode 100644 src/entity/attachments.rs create mode 100644 src/media.rs create mode 100644 src/migration/m20260227_000006_attachments.rs diff --git a/.env.example b/.env.example index 04a1dd8..c062b55 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,10 @@ TURN_PASSWORD=replace-me # 32+ random chars; used to sign session cookies SESSION_SECRET=replace-with-long-random-secret COOKIE_SECURE=false + +# Cloudflare R2 media uploads +R2_ACCOUNT_ID=replace-me +R2_ACCESS_KEY_ID=replace-me +R2_SECRET_ACCESS_KEY=replace-me +R2_BUCKET=chattz-media +R2_PUBLIC_BASE_URL=https://media.example.com diff --git a/Cargo.lock b/Cargo.lock index 190bd84..b496651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,418 @@ 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" @@ -104,10 +516,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -138,8 +550,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -166,6 +578,16 @@ 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" @@ -208,6 +630,16 @@ 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" @@ -215,6 +647,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -235,6 +669,8 @@ name = "chattz" version = "0.1.0" dependencies = [ "anyhow", + "aws-config", + "aws-sdk-s3", "axum", "chrono", "dotenvy", @@ -267,6 +703,15 @@ 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" @@ -282,6 +727,16 @@ 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" @@ -312,6 +767,28 @@ 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" @@ -448,6 +925,12 @@ 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" @@ -494,6 +977,12 @@ 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" @@ -532,6 +1021,12 @@ 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" @@ -673,6 +1168,44 @@ 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" @@ -744,6 +1277,17 @@ 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" @@ -754,6 +1298,17 @@ 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" @@ -761,7 +1316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -772,8 +1327,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -795,6 +1350,30 @@ 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" @@ -805,8 +1384,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -817,19 +1397,35 @@ 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", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.6", ] @@ -844,14 +1440,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1032,6 +1628,16 @@ 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" @@ -1120,6 +1726,15 @@ 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" @@ -1193,7 +1808,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1278,6 +1893,12 @@ 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" @@ -1311,6 +1932,12 @@ 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" @@ -1484,8 +2111,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.36", + "socket2 0.6.2", "thiserror", "tokio", "tracing", @@ -1504,7 +2131,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror", @@ -1522,7 +2149,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -1642,6 +2269,12 @@ 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" @@ -1657,25 +2290,25 @@ dependencies = [ "base64", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -1735,20 +2368,45 @@ 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", + "rustls-webpki 0.103.9", "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" @@ -1759,12 +2417,23 @@ 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", @@ -1782,12 +2451,31 @@ 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" @@ -1935,6 +2623,29 @@ 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" @@ -2081,6 +2792,16 @@ 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" @@ -2147,7 +2868,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.36", "serde", "serde_json", "sha2", @@ -2469,7 +3190,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2485,13 +3206,23 @@ 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", + "rustls 0.23.36", "tokio", ] @@ -2557,8 +3288,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -2663,7 +3394,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -2783,6 +3514,12 @@ 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" @@ -3213,6 +3950,12 @@ 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 5ab0c14..d7ece96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ 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" diff --git a/src/config.rs b/src/config.rs index 0696d55..8f55aa0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,16 @@ pub struct Settings { 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 { @@ -33,7 +43,8 @@ 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()), + 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()) @@ -43,10 +54,42 @@ impl Settings { 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("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: public_base_url.context("missing env var R2_PUBLIC_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}")) } diff --git a/src/db.rs b/src/db.rs index d4364bc..42d980b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,12 +9,12 @@ use uuid::Uuid; use crate::{ entity::{ - channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds, - users, + attachments, channels, direct_messages, guild_members, guilds, invites, messages, + soundboard_sounds, users, }, models::{ - BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, - SoundboardSound, User, + Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, + MessageWithAuthor, SoundboardSound, User, }, }; @@ -350,6 +350,63 @@ pub async fn create_message( author_user_id: model.author_user_id, author_display_name: user.display_name, body: model.body, + attachments: Vec::new(), + created_at: model.created_at, + }) +} + +pub async fn create_message_with_attachment( + db: &DatabaseConnection, + channel_id: Uuid, + author_user_id: Uuid, + body: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, + original_filename: &str, +) -> Result { + let txn = db.begin().await?; + + let model = messages::Entity::insert(messages::ActiveModel { + id: Set(Uuid::new_v4()), + channel_id: Set(channel_id), + author_user_id: Set(author_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let attachment = attachments::Entity::insert(attachments::ActiveModel { + id: Set(Uuid::new_v4()), + channel_message_id: Set(Some(model.id)), + direct_message_id: Set(None), + uploader_user_id: Set(author_user_id), + object_key: Set(object_key.to_string()), + media_url: Set(media_url.to_string()), + mime_type: Set(mime_type.to_string()), + size_bytes: Set(size_bytes), + original_filename: Set(original_filename.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let user = users::Entity::find_by_id(author_user_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("author not found"))?; + + txn.commit().await?; + + Ok(MessageWithAuthor { + id: model.id, + channel_id: model.channel_id, + author_user_id: model.author_user_id, + author_display_name: user.display_name, + body: model.body, + attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -367,6 +424,10 @@ pub async fn list_messages( .all(db) .await?; + let attachment_map = + list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect()) + .await?; + Ok(rows .into_iter() .map(|(msg, user)| { @@ -379,6 +440,7 @@ pub async fn list_messages( author_user_id: msg.author_user_id, author_display_name, body: msg.body, + attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, } }) @@ -412,6 +474,63 @@ pub async fn create_direct_message( recipient_user_id: model.recipient_user_id, author_display_name: user.display_name, body: model.body, + attachments: Vec::new(), + created_at: model.created_at, + }) +} + +pub async fn create_direct_message_with_attachment( + db: &DatabaseConnection, + sender_user_id: Uuid, + recipient_user_id: Uuid, + body: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, + original_filename: &str, +) -> Result { + let txn = db.begin().await?; + + let model = direct_messages::Entity::insert(direct_messages::ActiveModel { + id: Set(Uuid::new_v4()), + sender_user_id: Set(sender_user_id), + recipient_user_id: Set(recipient_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let attachment = attachments::Entity::insert(attachments::ActiveModel { + id: Set(Uuid::new_v4()), + channel_message_id: Set(None), + direct_message_id: Set(Some(model.id)), + uploader_user_id: Set(sender_user_id), + object_key: Set(object_key.to_string()), + media_url: Set(media_url.to_string()), + mime_type: Set(mime_type.to_string()), + size_bytes: Set(size_bytes), + original_filename: Set(original_filename.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let user = users::Entity::find_by_id(sender_user_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("sender not found"))?; + + txn.commit().await?; + + Ok(DmMessageWithAuthor { + id: model.id, + author_user_id: model.sender_user_id, + recipient_user_id: model.recipient_user_id, + author_display_name: user.display_name, + body: model.body, + attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -454,6 +573,9 @@ pub async fn list_direct_messages( .map(|u| (u.id, u.display_name)) .collect(); + let attachment_map = + list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?; + Ok(rows .into_iter() .map(|msg| DmMessageWithAuthor { @@ -465,11 +587,64 @@ pub async fn list_direct_messages( .cloned() .unwrap_or_else(|| "Unknown User".to_string()), body: msg.body, + attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, }) .collect()) } +async fn list_attachments_for_channel_messages( + db: &DatabaseConnection, + message_ids: Vec, +) -> 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, @@ -543,6 +718,16 @@ fn map_guild(model: guilds::Model) -> Guild { } } +fn map_attachment(model: attachments::Model) -> Attachment { + Attachment { + id: model.id, + media_url: model.media_url, + mime_type: model.mime_type, + size_bytes: model.size_bytes, + original_filename: model.original_filename, + } +} + fn map_channel(model: channels::Model) -> Channel { Channel { id: model.id, diff --git a/src/entity/attachments.rs b/src/entity/attachments.rs new file mode 100644 index 0000000..f5581d1 --- /dev/null +++ b/src/entity/attachments.rs @@ -0,0 +1,66 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "attachments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub channel_message_id: Option, + pub direct_message_id: Option, + pub uploader_user_id: Uuid, + pub object_key: String, + pub media_url: String, + pub mime_type: String, + pub size_bytes: i64, + pub original_filename: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::messages::Entity", + from = "Column::ChannelMessageId", + to = "super::messages::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Messages, + #[sea_orm( + belongs_to = "super::direct_messages::Entity", + from = "Column::DirectMessageId", + to = "super::direct_messages::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + DirectMessages, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UploaderUserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Messages.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DirectMessages.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/mod.rs b/src/entity/mod.rs index d1e64f7..d5dda3d 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,3 +1,4 @@ +pub mod attachments; pub mod channels; pub mod direct_messages; pub mod guild_members; diff --git a/src/handlers.rs b/src/handlers.rs index 9337339..5c2f27b 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,10 +1,11 @@ use axum::{ Json, Router, - extract::{Path, Query, State, WebSocketUpgrade}, + extract::{Multipart, Path, Query, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; +use chrono::Datelike; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -12,7 +13,7 @@ use crate::{ AppState, auth::{self, ApiError, AuthUser}, chat, db, - models::{Guild, SoundboardSound}, + models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound}, voice, }; use tracing::info; @@ -30,6 +31,10 @@ pub fn routes() -> Router { "/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message), ) + .route( + "/dms/{other_user_id}/attachments", + post(upload_dm_attachment), + ) .route("/presence", get(presence_list)) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) @@ -54,6 +59,10 @@ pub fn routes() -> Router { "/channels/{channel_id}/messages", get(list_messages).post(send_message), ) + .route( + "/channels/{channel_id}/attachments", + post(upload_channel_attachment), + ) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) .route("/ws", get(chat_ws)) } @@ -680,6 +689,89 @@ async fn send_dm_message( Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } +async fn upload_channel_attachment( + State(state): State, + user: AuthUser, + Path(channel_id): Path, + multipart: Multipart, +) -> Result { + ensure_channel_member(&state, channel_id, user.id).await?; + let uploaded = + upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id)) + .await?; + + let message = db::create_message_with_attachment( + &state.db, + channel_id, + user.id, + "", + &uploaded.object_key, + &uploaded.media_url, + &uploaded.mime_type, + uploaded.size_bytes, + &uploaded.original_filename, + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to create message attachment: {e}")))?; + + let guild_id = db::guild_id_for_channel(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? + .ok_or_else(|| ApiError::internal("channel not found after attachment upload"))?; + + let members = db::list_guild_member_ids(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?; + + broadcast_channel_message(&state, members, channel_id, &message).await; + Ok((StatusCode::CREATED, Json(message))) +} + +async fn upload_dm_attachment( + State(state): State, + user: AuthUser, + Path(other_user_id): Path, + multipart: Multipart, +) -> Result { + if other_user_id == user.id { + return Err(ApiError::bad_request("cannot send dm to yourself")); + } + + let other_user_exists = db::user_exists(&state.db, other_user_id) + .await + .map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?; + if !other_user_exists { + return Err(ApiError { + status: StatusCode::NOT_FOUND, + message: "user not found".to_string(), + }); + } + + let uploaded = upload_media_from_multipart( + &state, + multipart, + dm_object_key_prefix(user.id, other_user_id), + ) + .await?; + + let message = db::create_direct_message_with_attachment( + &state.db, + user.id, + other_user_id, + "", + &uploaded.object_key, + &uploaded.media_url, + &uploaded.mime_type, + uploaded.size_bytes, + &uploaded.original_filename, + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to create dm attachment: {e}")))?; + + broadcast_dm_message(&state, user.id, other_user_id, &message).await; + Ok((StatusCode::CREATED, Json(message))) +} + async fn voice_ws( ws: WebSocketUpgrade, State(state): State, @@ -794,6 +886,171 @@ async fn upload_sound( Ok(Json(sound)) } +const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024; + +struct UploadedMedia { + object_key: String, + media_url: String, + mime_type: String, + size_bytes: i64, + original_filename: String, +} + +async fn upload_media_from_multipart( + state: &AppState, + mut multipart: Multipart, + object_key_prefix: String, +) -> Result { + let storage = state + .media + .as_ref() + .ok_or_else(|| ApiError::internal("media storage is not configured"))?; + + let mut file_name = None; + let mut file_data = None; + let mut mime_type = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))? + { + if field.name().unwrap_or_default() != "file" { + continue; + } + + file_name = Some(field.file_name().unwrap_or("upload.bin").to_string()); + mime_type = Some( + field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(), + ); + let bytes = field + .bytes() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))?; + if bytes.len() > MAX_MEDIA_UPLOAD_BYTES { + return Err(ApiError::bad_request("file exceeds 25MB upload limit")); + } + file_data = Some(bytes.to_vec()); + break; + } + + let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) { + (Some(name), Some(mime), Some(data)) => (name, mime, data), + _ => return Err(ApiError::bad_request("missing file upload")), + }; + + let safe_name = sanitize_file_name(&original_filename); + let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name); + let size_bytes = file_data.len() as i64; + let media_url = storage + .upload_object(&object_key, file_data, &mime_type, &original_filename) + .await + .map_err(|e| ApiError::internal(&e.to_string()))?; + + Ok(UploadedMedia { + object_key, + media_url, + mime_type, + size_bytes, + original_filename, + }) +} + +async fn broadcast_channel_message( + state: &AppState, + members: Vec, + channel_id: Uuid, + message: &MessageWithAuthor, +) { + state + .chat + .broadcast_to_many( + members, + chat::ServerEvent::MessageCreated { + channel_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; +} + +async fn broadcast_dm_message( + state: &AppState, + current_user_id: Uuid, + other_user_id: Uuid, + message: &DmMessageWithAuthor, +) { + state + .chat + .broadcast_to_user( + current_user_id, + chat::ServerEvent::DmCreated { + other_user_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; + state + .chat + .broadcast_to_user( + other_user_id, + chat::ServerEvent::DmCreated { + other_user_id: current_user_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; +} + +fn sanitize_file_name(file_name: &str) -> String { + let sanitized: String = file_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + + let trimmed = sanitized.trim_matches('_').trim(); + if trimmed.is_empty() { + "upload.bin".to_string() + } else { + trimmed.to_string() + } +} + +fn channel_object_key_prefix(channel_id: Uuid) -> String { + let now = chrono::Utc::now(); + format!( + "channels/{}/{:04}/{:02}", + channel_id, + now.year(), + now.month() + ) +} + +fn dm_object_key_prefix(user_a: Uuid, user_b: Uuid) -> String { + let now = chrono::Utc::now(); + let (left, right) = if user_a <= user_b { + (user_a, user_b) + } else { + (user_b, user_a) + }; + format!( + "dms/{}-{}/{:04}/{:02}", + left, + right, + now.year(), + now.month() + ) +} + async fn delete_sound_post( state: State, user: AuthUser, diff --git a/src/main.rs b/src/main.rs index 53ea6f2..303636f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod db; mod entity; mod handlers; +mod media; mod migration; mod models; mod voice; @@ -17,7 +18,9 @@ use sea_orm_migration::MigratorTrait; use tower_http::{services::ServeDir, trace::TraceLayer}; use tracing::info; -use crate::{config::Settings, handlers::routes, migration::Migrator, voice::VoiceHub}; +use crate::{ + config::Settings, handlers::routes, media::MediaStorage, migration::Migrator, voice::VoiceHub, +}; #[derive(Clone)] pub struct AppState { @@ -26,6 +29,7 @@ pub struct AppState { pub http: reqwest::Client, pub voice: Arc, pub chat: Arc, + pub media: Option>, } #[tokio::main] @@ -43,12 +47,18 @@ async fn main() -> anyhow::Result<()> { .await .with_context(|| "failed to run migrations")?; + let media = match settings.media.as_ref() { + Some(media_settings) => Some(Arc::new(MediaStorage::new(media_settings).await?)), + None => None, + }; + let state = AppState { db, settings, http: reqwest::Client::new(), voice: Arc::new(VoiceHub::default()), chat: Arc::new(chat::ChatHub::default()), + media, }; let port = state.settings.port; diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..ee7bd23 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,74 @@ +use anyhow::{Result, anyhow}; +use aws_config::BehaviorVersion; +use aws_sdk_s3::{ + Client, + config::{Credentials, Region}, + primitives::ByteStream, +}; + +use crate::config::MediaSettings; + +#[derive(Clone)] +pub struct MediaStorage { + client: Client, + bucket: String, + public_base_url: String, +} + +impl MediaStorage { + pub async fn new(settings: &MediaSettings) -> Result { + let shared_config = aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new("auto")) + .credentials_provider(Credentials::new( + settings.access_key_id.clone(), + settings.secret_access_key.clone(), + None, + None, + "chattz-r2", + )) + .load() + .await; + + let config = aws_sdk_s3::config::Builder::from(&shared_config) + .endpoint_url(settings.endpoint_url()) + .force_path_style(true) + .build(); + + Ok(Self { + client: Client::from_conf(config), + bucket: settings.bucket.clone(), + public_base_url: settings.public_base_url.trim_end_matches('/').to_string(), + }) + } + + pub async fn upload_object( + &self, + object_key: &str, + bytes: Vec, + content_type: &str, + original_filename: &str, + ) -> Result { + self.client + .put_object() + .bucket(&self.bucket) + .key(object_key) + .body(ByteStream::from(bytes)) + .content_type(content_type) + .content_disposition(format!( + "inline; filename=\"{}\"", + sanitize_header_value(original_filename) + )) + .send() + .await + .map_err(|e| anyhow!("failed to upload object to R2: {e}"))?; + + Ok(format!("{}/{}", self.public_base_url, object_key)) + } +} + +fn sanitize_header_value(value: &str) -> String { + value + .chars() + .filter(|c| *c != '\\' && *c != '"' && !c.is_control()) + .collect() +} diff --git a/src/migration/m20260213_000001_init.rs b/src/migration/m20260213_000001_init.rs index 9c671d4..3c1d1ff 100644 --- a/src/migration/m20260213_000001_init.rs +++ b/src/migration/m20260213_000001_init.rs @@ -11,13 +11,13 @@ impl MigrationTrait for Migration { Table::create() .table(Users::Table) .if_not_exists() + .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key()) .col( - ColumnDef::new(Users::Id) - .uuid() + ColumnDef::new(Users::OidcSub) + .string() .not_null() - .primary_key(), + .unique_key(), ) - .col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key()) .col(ColumnDef::new(Users::Email).string()) .col(ColumnDef::new(Users::DisplayName).string().not_null()) .col(ColumnDef::new(Users::AvatarUrl).string()) @@ -42,12 +42,7 @@ impl MigrationTrait for Migration { Table::create() .table(Guilds::Table) .if_not_exists() - .col( - ColumnDef::new(Guilds::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Guilds::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Guilds::Name).string().not_null()) .col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null()) .col( @@ -108,12 +103,7 @@ impl MigrationTrait for Migration { Table::create() .table(Channels::Table) .if_not_exists() - .col( - ColumnDef::new(Channels::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Channels::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Channels::GuildId).uuid().not_null()) .col(ColumnDef::new(Channels::Name).string().not_null()) .col( @@ -138,12 +128,7 @@ impl MigrationTrait for Migration { Table::create() .table(Messages::Table) .if_not_exists() - .col( - ColumnDef::new(Messages::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Messages::ChannelId).uuid().not_null()) .col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null()) .col(ColumnDef::new(Messages::Body).text().not_null()) diff --git a/src/migration/m20260213_000002_invites.rs b/src/migration/m20260213_000002_invites.rs index e99c28d..3dbcd5d 100644 --- a/src/migration/m20260213_000002_invites.rs +++ b/src/migration/m20260213_000002_invites.rs @@ -11,7 +11,12 @@ impl MigrationTrait for Migration { Table::create() .table(Invites::Table) .if_not_exists() - .col(ColumnDef::new(Invites::Code).string().not_null().primary_key()) + .col( + ColumnDef::new(Invites::Code) + .string() + .not_null() + .primary_key(), + ) .col(ColumnDef::new(Invites::GuildId).uuid().not_null()) .col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null()) .col( diff --git a/src/migration/m20260213_000004_direct_messages.rs b/src/migration/m20260213_000004_direct_messages.rs index 4b723ab..63ba7e2 100644 --- a/src/migration/m20260213_000004_direct_messages.rs +++ b/src/migration/m20260213_000004_direct_messages.rs @@ -17,8 +17,16 @@ impl MigrationTrait for Migration { .not_null() .primary_key(), ) - .col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null()) - .col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null()) + .col( + ColumnDef::new(DirectMessages::SenderUserId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(DirectMessages::RecipientUserId) + .uuid() + .not_null(), + ) .col(ColumnDef::new(DirectMessages::Body).text().not_null()) .col( ColumnDef::new(DirectMessages::CreatedAt) diff --git a/src/migration/m20260227_000006_attachments.rs b/src/migration/m20260227_000006_attachments.rs new file mode 100644 index 0000000..96f6da6 --- /dev/null +++ b/src/migration/m20260227_000006_attachments.rs @@ -0,0 +1,122 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Attachments::Table) + .if_not_exists() + .col( + ColumnDef::new(Attachments::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Attachments::ChannelMessageId).uuid().null()) + .col(ColumnDef::new(Attachments::DirectMessageId).uuid().null()) + .col(ColumnDef::new(Attachments::UploaderUserId).uuid().not_null()) + .col(ColumnDef::new(Attachments::ObjectKey).string().not_null()) + .col(ColumnDef::new(Attachments::MediaUrl).string().not_null()) + .col(ColumnDef::new(Attachments::MimeType).string().not_null()) + .col(ColumnDef::new(Attachments::SizeBytes).big_integer().not_null()) + .col(ColumnDef::new(Attachments::OriginalFilename).string().not_null()) + .col( + ColumnDef::new(Attachments::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_channel_message") + .from(Attachments::Table, Attachments::ChannelMessageId) + .to(Messages::Table, Messages::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_direct_message") + .from(Attachments::Table, Attachments::DirectMessageId) + .to(DirectMessages::Table, DirectMessages::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_uploader") + .from(Attachments::Table, Attachments::UploaderUserId) + .to(Users::Table, Users::Id), + ) + .check( + Expr::cust( + "(channel_message_id IS NOT NULL AND direct_message_id IS NULL) OR (channel_message_id IS NULL AND direct_message_id IS NOT NULL)", + ), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_attachments_channel_message") + .table(Attachments::Table) + .col(Attachments::ChannelMessageId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_attachments_direct_message") + .table(Attachments::Table) + .col(Attachments::DirectMessageId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Attachments::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Attachments { + Table, + Id, + ChannelMessageId, + DirectMessageId, + UploaderUserId, + ObjectKey, + MediaUrl, + MimeType, + SizeBytes, + OriginalFilename, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Messages { + Table, + Id, +} + +#[derive(DeriveIden)] +enum DirectMessages { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index 4db4f51..1ac95c9 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -5,6 +5,7 @@ mod m20260213_000002_invites; mod m20260213_000003_channel_kind; mod m20260213_000004_direct_messages; mod m20260224_000005_soundboard; +mod m20260227_000006_attachments; pub struct Migrator; @@ -17,6 +18,7 @@ impl MigratorTrait for Migrator { Box::new(m20260213_000003_channel_kind::Migration), Box::new(m20260213_000004_direct_messages::Migration), Box::new(m20260224_000005_soundboard::Migration), + Box::new(m20260227_000006_attachments::Migration), ] } } diff --git a/src/models.rs b/src/models.rs index 74ac74a..aabf16d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -39,6 +39,15 @@ pub struct Message { pub created_at: DateTimeWithTimeZone, } +#[derive(Debug, Clone, Serialize)] +pub struct Attachment { + pub id: Uuid, + pub media_url: String, + pub mime_type: String, + pub size_bytes: i64, + pub original_filename: String, +} + #[derive(Debug, Clone, Serialize)] pub struct BasicUser { pub id: Uuid, @@ -46,13 +55,14 @@ pub struct BasicUser { pub avatar_url: Option, } -#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)] +#[derive(Debug, Clone, Serialize)] pub struct MessageWithAuthor { pub id: Uuid, pub channel_id: Uuid, pub author_user_id: Uuid, pub author_display_name: String, pub body: String, + pub attachments: Vec, pub created_at: DateTimeWithTimeZone, } @@ -71,6 +81,7 @@ pub struct DmMessageWithAuthor { pub recipient_user_id: Uuid, pub author_display_name: String, pub body: String, + pub attachments: Vec, pub created_at: DateTimeWithTimeZone, } diff --git a/static/index.html b/static/index.html index 5aef5f7..6386f77 100644 --- a/static/index.html +++ b/static/index.html @@ -144,6 +144,10 @@
+ + diff --git a/static/shared/app-core.js b/static/shared/app-core.js index a7ad4e1..0aa2e4f 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -183,6 +183,8 @@ const el = { messageList: document.getElementById("message-list"), messageForm: document.getElementById("message-form"), messageBody: document.getElementById("message-body"), + mediaUploadBtn: document.getElementById("media-upload-btn"), + mediaFileInput: document.getElementById("media-file-input"), // User Panel userName: document.getElementById("user-name"), @@ -343,6 +345,43 @@ function formatMessageBody(body) { return escaped; } +function formatBytes(size) { + if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`; + const units = ["KB", "MB", "GB"]; + let value = size; + let unitIndex = -1; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function renderAttachments(attachments = []) { + if (!attachments.length) return ""; + + return attachments.map((attachment) => { + const url = escapeHtml(attachment.media_url); + const mime = attachment.mime_type || "application/octet-stream"; + const fileName = escapeHtml(attachment.original_filename || "file"); + const size = formatBytes(attachment.size_bytes); + + if (mime.startsWith("image/")) { + return `${fileName}`; + } + + if (mime.startsWith("video/")) { + return `
`; + } + + if (mime.startsWith("audio/")) { + return ``; + } + + return `${fileName}${size}`; + }).join(""); +} + function formatDate(isoString) { const d = new Date(isoString); const now = new Date(); @@ -609,9 +648,13 @@ function renderMessages(messages) { row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; const displayName = m.author_display_name || "Unknown User"; + const formattedBody = formatMessageBody(m.body || ""); + const attachmentsHtml = renderAttachments(m.attachments); + const bodyHtml = formattedBody ? `
${formattedBody}
` : ""; + const contentHtml = `${bodyHtml}${attachmentsHtml}`; if (isGrouped) { - row.innerHTML = `
${formatMessageBody(m.body)}
`; + row.innerHTML = `
${contentHtml}
`; } else { row.innerHTML = `
${shortName(displayName)}
@@ -620,7 +663,7 @@ function renderMessages(messages) { ${escapeHtml(displayName)} ${formatDate(m.created_at)}
-
${formatMessageBody(m.body)}
+ ${contentHtml} `; } @@ -644,6 +687,15 @@ function renderMessages(messages) { gifImage.addEventListener("load", scrollMessagesToBottom, { once: true }); gifImage.addEventListener("error", scrollMessagesToBottom, { once: true }); } + + for (const media of el.messageList.querySelectorAll(".msg-attachment img, .msg-attachment video, .msg-attachment audio")) { + if ("complete" in media && media.complete) continue; + media.addEventListener("loadeddata", scrollMessagesToBottom, { once: true }); + media.addEventListener("load", scrollMessagesToBottom, { once: true }); + media.addEventListener("error", scrollMessagesToBottom, { once: true }); + } + + lucide.createIcons(); } function renderMembers() { @@ -1862,6 +1914,26 @@ async function init() { } catch (err) { alert(err.message); } }; + el.mediaUploadBtn.onclick = () => { + if (!state.selectedTextChannelId && !state.selectedDmUserId) { + alert("Select a chat first."); + return; + } + el.mediaFileInput.click(); + }; + + el.mediaFileInput.onchange = async () => { + const file = el.mediaFileInput.files?.[0]; + if (!file) return; + try { + await uploadMediaFile(file); + } catch (err) { + alert(err.message); + } finally { + el.mediaFileInput.value = ""; + } + }; + // 4. Voice/Soundboard Controls el.voiceVideoBtn.onclick = toggleVideo; el.voiceScreenBtn.onclick = toggleScreenShare; @@ -1962,6 +2034,54 @@ async function init() { } } + async function uploadMediaFile(file) { + const token = storageGet("chattz_token"); + if (!token) throw new Error("You need to log in again."); + + const path = state.selectedTextChannelId + ? `/channels/${state.selectedTextChannelId}/attachments` + : state.selectedDmUserId + ? `/dms/${state.selectedDmUserId}/attachments` + : null; + if (!path) throw new Error("Select a chat first."); + + const formData = new FormData(); + formData.append("file", file); + + el.mediaUploadBtn.disabled = true; + el.mediaUploadBtn.title = "Uploading..."; + + const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path; + try { + const response = await fetch(fullUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + let detail = "upload failed"; + try { + const payload = await response.json(); + detail = payload.error || detail; + } catch { } + throw new Error(`${response.status}: ${detail}`); + } + + const messages = state.selectedTextChannelId + ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) + : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); + renderMessages(messages); + await loadDMConversations(); + renderDMs(); + } finally { + el.mediaUploadBtn.disabled = false; + el.mediaUploadBtn.title = "Upload File"; + } + } + // 6. Initial Data Loading & App Start try { state.me = await api("/me"); diff --git a/static/styles.css b/static/styles.css index 9147a37..aac3dd9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -885,6 +885,10 @@ select { color: var(--text-normal); } +.hidden-file-input { + display: none; +} + /* ═══════════════════════════════════════════════════════════ */ /* GIF Picker Modal */ /* ═══════════════════════════════════════════════════════════ */ @@ -996,6 +1000,91 @@ select { object-fit: contain; } +.msg-attachment { + display: block; + margin-top: 8px; +} + +.msg-attachment-image { + max-width: min(440px, 100%); + border-radius: var(--radius-md); + overflow: hidden; +} + +.msg-attachment-image img { + width: 100%; + max-height: 360px; + object-fit: contain; + display: block; + background: rgba(0, 0, 0, 0.18); +} + +.msg-attachment-video { + max-width: min(520px, 100%); +} + +.msg-attachment-video video { + width: 100%; + max-height: 420px; + border-radius: var(--radius-md); + background: #000; +} + +.msg-attachment-audio { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + max-width: min(420px, 100%); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.msg-attachment-audio audio { + width: 100%; +} + +.msg-attachment-audio a { + color: var(--text-link); + font-size: 13px; +} + +.msg-attachment-file { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + max-width: min(420px, 100%); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + color: var(--text-normal); +} + +.msg-attachment-file:hover { + background: rgba(255, 255, 255, 0.07); +} + +.msg-attachment-file i { + width: 18px; + height: 18px; + color: var(--text-link); + flex-shrink: 0; +} + +.msg-attachment-file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.msg-attachment-file small { + color: var(--text-muted); + margin-left: auto; + white-space: nowrap; +} + /* ═══════════════════════════════════════════════════════════ */ /* Sound Board */ /* ═══════════════════════════════════════════════════════════ */ @@ -1618,4 +1707,4 @@ select { .chat-header { padding: 0 8px; } -} \ No newline at end of file +}