diff --git a/Cargo.lock b/Cargo.lock index 8804b5546..da497d7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,12 +162,52 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.28.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07a9b245ba0739fc90935094c29adbaee3f977218b5fb95e822e261cda7f56a3" +dependencies = [ + "http 1.4.0", + "log", + "native-tls", + "serde", + "serde_json", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-creds" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f84143206b9c72b3c5cb65415de60c7539c79cd1559290fddec657939131be0" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml", + "rust-ini", + "serde", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "aws-region" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9aed3f9c7eac9be28662fdb3b0f4d1951e812f7c64fed4f0327ba702f459b3b" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "axum" version = "0.8.8" @@ -180,10 +220,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", @@ -213,8 +253,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", @@ -364,18 +404,36 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blurhash" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -401,6 +459,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -502,6 +571,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -554,6 +629,36 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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" @@ -637,6 +742,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -751,6 +862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -776,6 +888,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -847,6 +968,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -874,12 +1004,33 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1038,6 +1189,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1130,6 +1291,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" @@ -1140,6 +1312,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" @@ -1147,7 +1330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1158,8 +1341,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1175,6 +1358,29 @@ 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", + "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" @@ -1185,8 +1391,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1203,8 +1409,8 @@ 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-pki-types", @@ -1214,6 +1420,19 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1224,9 +1443,9 @@ 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", @@ -1375,6 +1594,40 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.13.0" @@ -1387,6 +1640,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -1520,6 +1782,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1562,6 +1830,17 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1572,6 +1851,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1584,6 +1869,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minidom" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f45614075738ce1b77a1768912a60c0227525971b03e09122a05b8a34a2a6278" +dependencies = [ + "rxml", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1628,6 +1922,33 @@ dependencies = [ "uuid", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "negentropy" version = "0.3.1" @@ -1781,6 +2102,60 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "parking" version = "2.2.1" @@ -1907,6 +2282,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1967,6 +2355,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1981,7 +2391,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.3", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2002,7 +2412,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2192,10 +2602,10 @@ dependencies = [ "base64", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-rustls", "hyper-util", "js-sys", @@ -2251,7 +2661,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -2290,12 +2700,72 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust-s3" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3df3f353b1f4209dcf437d777cda90279c397ab15a0cd6fd06bd32c88591533" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64", + "bytes", + "cfg-if", + "futures", + "hex", + "hmac", + "http 0.2.12", + "hyper 0.14.32", + "hyper-tls", + "log", + "maybe-async", + "md5", + "minidom", + "native-tls", + "percent-encoding", + "quick-xml", + "serde", + "serde_derive", + "serde_json", + "sha2", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-native-tls", + "tokio-stream", + "url", +] + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2337,6 +2807,23 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rxml" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98f186c7a2f3abbffb802984b7f1dfd65dac8be1aafdaabbca4137f53f0dff7" +dependencies = [ + "bytes", + "rxml_validation", + "smartstring", +] + +[[package]] +name = "rxml_validation" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a197350ece202f19a166d1ad6d9d6de145e1d2a8ef47db299abe164dbd7530" + [[package]] name = "ryu" version = "1.0.23" @@ -2352,6 +2839,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.1" @@ -2417,6 +2913,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +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" @@ -2600,7 +3119,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2619,6 +3138,17 @@ dependencies = [ "serde", ] +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "socket2" version = "0.5.10" @@ -2672,7 +3202,7 @@ dependencies = [ "serde", "serde_json", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", "toml", @@ -2708,7 +3238,7 @@ dependencies = [ "sha2", "sprout-core", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2729,7 +3259,7 @@ dependencies = [ "sha2", "sprout-core", "subtle", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -2746,7 +3276,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "uuid", ] @@ -2762,7 +3292,7 @@ dependencies = [ "sha2", "sprout-core", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2781,7 +3311,7 @@ dependencies = [ "serde_json", "sha2", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2800,7 +3330,7 @@ dependencies = [ "serde", "serde_json", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", "tracing", @@ -2809,6 +3339,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "sprout-media" +version = "0.1.0" +dependencies = [ + "axum", + "blurhash", + "bytes", + "chrono", + "hex", + "image", + "imagesize", + "infer", + "nostr", + "rust-s3", + "serde", + "serde_json", + "sha2", + "sprout-core", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "sprout-proxy" version = "0.1.0" @@ -2826,7 +3379,7 @@ dependencies = [ "serde_json", "sha2", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", "tower-http", @@ -2849,7 +3402,7 @@ dependencies = [ "serde_json", "sprout-auth", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2862,11 +3415,13 @@ dependencies = [ "anyhow", "axum", "base64", + "bytes", "chrono", "dashmap", "deadpool-redis", "futures-util", "hex", + "infer", "moka", "nostr", "redis", @@ -2878,11 +3433,12 @@ dependencies = [ "sprout-auth", "sprout-core", "sprout-db", + "sprout-media", "sprout-pubsub", "sprout-search", "sprout-workflow", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tower", @@ -2903,7 +3459,7 @@ dependencies = [ "serde", "serde_json", "sprout-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2919,13 +3475,14 @@ dependencies = [ "futures-util", "hex", "nostr", + "rand 0.8.5", "reqwest", "serde", "serde_json", "sha2", "sprout-core", "sprout-mcp", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", "tracing", @@ -2950,7 +3507,7 @@ dependencies = [ "serde_yaml", "sprout-core", "sprout-db", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -2998,7 +3555,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -3083,7 +3640,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3122,7 +3679,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3148,7 +3705,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -3160,6 +3717,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -3220,13 +3783,46 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3280,6 +3876,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -3332,6 +3937,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3463,8 +4078,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "iri-string", "pin-project-lite", @@ -3577,14 +4192,14 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -3596,12 +4211,12 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -3897,6 +4512,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -4399,3 +5020,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 2aac4ffb9..4d9c560ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/sprout-test-client", "crates/sprout-admin", "crates/sprout-workflow", + "crates/sprout-media", ] exclude = ["desktop/src-tauri"] resolver = "2" @@ -105,3 +106,4 @@ sprout-mcp = { path = "crates/sprout-mcp" } sprout-proxy = { path = "crates/sprout-proxy" } sprout-huddle = { path = "crates/sprout-huddle" } sprout-workflow = { path = "crates/sprout-workflow" } +sprout-media = { path = "crates/sprout-media" } diff --git a/crates/sprout-audit/src/action.rs b/crates/sprout-audit/src/action.rs index 8f0c8ada7..be7ccc354 100644 --- a/crates/sprout-audit/src/action.rs +++ b/crates/sprout-audit/src/action.rs @@ -26,6 +26,8 @@ pub enum AuditAction { AuthFailure, /// A client exceeded the rate limit. RateLimitExceeded, + /// A media file was uploaded via the Blossom endpoint. + MediaUploaded, } impl AuditAction { @@ -42,6 +44,7 @@ impl AuditAction { Self::AuthSuccess => "auth_success", Self::AuthFailure => "auth_failure", Self::RateLimitExceeded => "rate_limit_exceeded", + Self::MediaUploaded => "media_uploaded", } } @@ -56,6 +59,7 @@ impl AuditAction { Self::AuthSuccess, Self::AuthFailure, Self::RateLimitExceeded, + Self::MediaUploaded, ]; } diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 04077254e..87f145f68 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -203,6 +203,10 @@ pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104; /// A huddle recording became available. pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105; +// Media (49000–49999) +/// Internal kind for media upload audit entries. Not a relay event kind. +pub const KIND_MEDIA_UPLOAD: u32 = 49001; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -284,6 +288,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_ENDED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_HUDDLE_RECORDING_AVAILABLE, + KIND_MEDIA_UPLOAD, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). diff --git a/crates/sprout-media/Cargo.toml b/crates/sprout-media/Cargo.toml new file mode 100644 index 000000000..996042267 --- /dev/null +++ b/crates/sprout-media/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "sprout-media" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Media storage, validation, and thumbnail generation for Sprout" + +[dependencies] +sprout-core = { workspace = true } +nostr = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +chrono = { workspace = true } +axum = { workspace = true } +s3 = { version = "0.35", package = "rust-s3" } +infer = "0.16" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +blurhash = "0.2" +imagesize = "0.13" +bytes = "1" + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/sprout-media/src/auth.rs b/crates/sprout-media/src/auth.rs new file mode 100644 index 000000000..079c4a087 --- /dev/null +++ b/crates/sprout-media/src/auth.rs @@ -0,0 +1,280 @@ +//! Blossom kind:24242 auth verification (BUD-11 compliant). + +use crate::error::MediaError; + +/// Verify kind:24242 event validity per BUD-11: +/// 1. Schnorr signature +/// 2. kind == 24242 +/// 3. `t` tag == "upload" +/// 4. `expiration` tag in the future +/// 5. `created_at` in the past (with 5s clock-skew tolerance) +/// 6. If `server` tags present, our domain must appear in at least one +/// +/// Does NOT check the `x` tag — that requires the body hash, computed later. +/// Call this BEFORE trusting the event's pubkey for scope resolution. +pub fn verify_blossom_auth_event( + auth_event: &nostr::Event, + server_domain: Option<&str>, +) -> Result<(), MediaError> { + // 1. Verify Schnorr signature + auth_event + .verify() + .map_err(|_| MediaError::InvalidSignature)?; + + // 2. Kind must be 24242 + if auth_event.kind.as_u16() != 24242 { + return Err(MediaError::InvalidAuthKind); + } + + // 2b. Content must be non-empty (BUD-11: "human readable string") + if auth_event.content.trim().is_empty() { + return Err(MediaError::InvalidAuthEvent); + } + + let mut found_t = false; + let mut found_exp = false; + let mut server_tags: Vec<&str> = Vec::new(); + let mut exp_value: u64 = 0; + + for tag in auth_event.tags.iter() { + let kind = tag.kind().to_string(); + match kind.as_str() { + "t" => { + if let Some(v) = tag.content() { + if v != "upload" { + return Err(MediaError::InvalidAuthVerb); + } + found_t = true; + } + } + "expiration" => { + if let Some(v) = tag.content() { + exp_value = v.parse().unwrap_or(0); + found_exp = true; + } + } + "server" => { + if let Some(v) = tag.content() { + server_tags.push(v); + } + } + _ => {} + } + } + + // 3. t tag required + if !found_t { + return Err(MediaError::MissingTag("t")); + } + + // 4. Expiration must exist and be in the future + if !found_exp { + return Err(MediaError::MissingTag("expiration")); + } + let now = nostr::Timestamp::now().as_u64(); + if exp_value <= now { + return Err(MediaError::TokenExpired); + } + + // 5. created_at must be recent: not in the future (5s tolerance) and not + // older than 10 minutes. This bounds the replay window — even if the + // expiration tag allows a longer lifetime, the token must have been + // freshly minted. + let created = auth_event.created_at.as_u64(); + if created > now + 5 { + return Err(MediaError::TimestampOutOfWindow); + } + const MAX_AGE_SECS: u64 = 600; // 10 minutes + if now > created + MAX_AGE_SECS { + return Err(MediaError::TimestampOutOfWindow); + } + + // 6. Server tag enforcement (BUD-11 §5): if server tags present, our domain must appear. + // Fail closed: if server_domain is unconfigured, reject tokens that carry server tags + // rather than silently accepting them. + if !server_tags.is_empty() { + match server_domain { + Some(domain) => { + if !server_tags.contains(&domain) { + return Err(MediaError::ServerMismatch); + } + } + None => { + // Server tags present but we don't know our own domain — reject. + return Err(MediaError::ServerMismatch); + } + } + } + + Ok(()) +} + +/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check. +/// +/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one +/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches"). +pub fn verify_blossom_upload_auth( + auth_event: &nostr::Event, + sha256: &str, + server_domain: Option<&str>, +) -> Result<(), MediaError> { + verify_blossom_auth_event(auth_event, server_domain)?; + + // At least one x tag must match the body sha256 (BUD-11 §6) + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); + + if !has_matching_x { + return Err(MediaError::HashMismatch); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", sha256]).unwrap(), + Tag::parse(&["expiration", &exp_str]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload sprout-media", tags) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn test_verify_valid() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_valid_auth(&keys, &sha256); + assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok()); + } + + #[test] + fn test_verify_auth_event_valid() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_valid_auth(&keys, &sha256); + assert!(verify_blossom_auth_event(&event, None).is_ok()); + } + + #[test] + fn test_verify_hash_mismatch() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_valid_auth(&keys, &sha256); + let wrong_hash = "b".repeat(64); + assert!(matches!( + verify_blossom_upload_auth(&event, &wrong_hash, None), + Err(MediaError::HashMismatch) + )); + } + + #[test] + fn test_verify_wrong_kind() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(27235), "wrong kind", tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth(&event, &sha256, None), + Err(MediaError::InvalidAuthKind) + )); + } + + #[test] + fn test_verify_multi_x_tags() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let other_hash = "b".repeat(64); + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &other_hash]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-x", tags) + .sign_with_keys(&keys) + .unwrap(); + // Should pass because at least one x tag matches + assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok()); + } + + #[test] + fn test_server_tag_enforcement() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &exp_str]).unwrap(), + Tag::parse(&["server", "other.example.com"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload scoped", tags) + .sign_with_keys(&keys) + .unwrap(); + // Should fail — server tag present but doesn't match our domain + assert!(matches!( + verify_blossom_upload_auth(&event, &sha256, Some("sprout.example.com")), + Err(MediaError::ServerMismatch) + )); + // Should pass when our domain matches + assert!(verify_blossom_upload_auth(&event, &sha256, Some("other.example.com")).is_ok()); + // Should fail when server_domain is None — fail closed + assert!(matches!( + verify_blossom_upload_auth(&event, &sha256, None), + Err(MediaError::ServerMismatch) + )); + } + + #[test] + fn test_no_server_tags_always_passes() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_valid_auth(&keys, &sha256); + // No server tags → passes regardless of our domain + assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com")).is_ok()); + } + + #[test] + fn test_empty_content_rejected() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &exp_str]).unwrap(), + ]; + // Empty content — BUD-11 requires a human-readable string + let event = EventBuilder::new(Kind::from(24242), "", tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_auth_event(&event, None), + Err(MediaError::InvalidAuthEvent) + )); + } +} diff --git a/crates/sprout-media/src/config.rs b/crates/sprout-media/src/config.rs new file mode 100644 index 000000000..058910da6 --- /dev/null +++ b/crates/sprout-media/src/config.rs @@ -0,0 +1,51 @@ +//! Media storage configuration. + +/// Configuration for media storage (S3/MinIO). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct MediaConfig { + /// S3-compatible endpoint URL (e.g. "http://localhost:9000"). + pub s3_endpoint: String, + /// S3 access key. + pub s3_access_key: String, + /// S3 secret key. + pub s3_secret_key: String, + /// S3 bucket name. + pub s3_bucket: String, + /// Maximum upload size for images (bytes). Default: 50 MB. + pub max_image_bytes: u64, + /// Maximum upload size for animated GIFs (bytes). Default: 10 MB. + pub max_gif_bytes: u64, + /// Public base URL for media URLs in BlobDescriptor (must include `/media` path). + pub public_base_url: String, + /// Server authority for BUD-11 server tag validation. + /// Format: `host` for default ports, `host:port` for non-default ports. + /// Examples: "sprout.example.com", "localhost:3000", "relay.example.com:8080". + /// If None, auth events carrying `server` tags are rejected (fail-closed). + /// Must match the authority the desktop signer derives from the relay URL. + pub server_domain: Option, +} + +impl MediaConfig { + /// Validate configuration at startup. Returns an error on invalid config. + pub fn validate(&self) -> Result<(), String> { + if !self.public_base_url.ends_with("/media") { + return Err(format!( + "public_base_url must end with /media: got '{}'", + self.public_base_url + )); + } + if self.public_base_url.ends_with('/') { + return Err(format!( + "public_base_url must not end with /: got '{}'", + self.public_base_url + )); + } + if self.max_image_bytes == 0 { + return Err("max_image_bytes must be > 0".to_string()); + } + if self.max_gif_bytes == 0 || self.max_gif_bytes > self.max_image_bytes { + return Err("max_gif_bytes must be > 0 and <= max_image_bytes".to_string()); + } + Ok(()) + } +} diff --git a/crates/sprout-media/src/error.rs b/crates/sprout-media/src/error.rs new file mode 100644 index 000000000..8ea52c73d --- /dev/null +++ b/crates/sprout-media/src/error.rs @@ -0,0 +1,122 @@ +//! Media error types. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +/// Errors from media operations. +#[derive(Debug, thiserror::Error)] +pub enum MediaError { + #[error("unknown content type")] + UnknownContentType, + #[error("disallowed content type: {0}")] + DisallowedContentType(String), + #[error("file too large: {size} bytes (max {max})")] + FileTooLarge { size: u64, max: u64 }, + #[error("image dimensions too large")] + ImageTooLarge, + #[error("invalid image data")] + InvalidImage, + #[error("invalid signature")] + InvalidSignature, + #[error("invalid auth event kind")] + InvalidAuthKind, + #[error("invalid auth verb")] + InvalidAuthVerb, + #[error("missing required tag: {0}")] + MissingTag(&'static str), + #[error("hash mismatch")] + HashMismatch, + #[error("server mismatch")] + ServerMismatch, + #[error("token expired")] + TokenExpired, + #[error("timestamp out of window")] + TimestampOutOfWindow, + #[error("storage error: {0}")] + StorageError(String), + #[error("internal error")] + Internal, + #[error("not found")] + NotFound, + #[error("missing authorization header")] + MissingAuth, + #[error("invalid authorization scheme")] + InvalidAuthScheme, + #[error("invalid base64 encoding")] + InvalidBase64, + #[error("invalid auth event")] + InvalidAuthEvent, + #[error("unauthorized")] + Unauthorized, + #[error("insufficient scope")] + InsufficientScope, + #[error("token revoked")] + TokenRevoked, + #[error("pubkey mismatch")] + PubkeyMismatch, +} + +impl From for MediaError { + fn from(_: image::ImageError) -> Self { + Self::InvalidImage + } +} + +impl From for MediaError { + fn from(e: s3::error::S3Error) -> Self { + Self::StorageError(e.to_string()) + } +} + +impl From for MediaError { + fn from(e: serde_json::Error) -> Self { + Self::StorageError(e.to_string()) + } +} + +impl IntoResponse for MediaError { + fn into_response(self) -> Response { + let (status, msg) = match &self { + Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + Self::DisallowedContentType(_) => { + (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()) + } + Self::FileTooLarge { .. } | Self::ImageTooLarge => { + (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()) + } + // All authentication failures return the same generic 401 to prevent oracle enumeration. + // InsufficientScope is intentionally 403 — it's an authorization (not authentication) + // failure and is safe to distinguish since it requires a valid identity first. + Self::MissingAuth + | Self::InvalidAuthScheme + | Self::InvalidBase64 + | Self::InvalidAuthEvent + | Self::InvalidSignature + | Self::InvalidAuthKind + | Self::InvalidAuthVerb + | Self::TokenExpired + | Self::TimestampOutOfWindow + | Self::Unauthorized + | Self::TokenRevoked + | Self::PubkeyMismatch + | Self::HashMismatch + | Self::ServerMismatch + | Self::MissingTag(_) => { + tracing::warn!(error = %self, "authentication failed"); + ( + StatusCode::UNAUTHORIZED, + "authentication failed".to_string(), + ) + } + Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()), + Self::UnknownContentType | Self::InvalidImage => { + (StatusCode::BAD_REQUEST, self.to_string()) + } + Self::StorageError(_) | Self::Internal => { + tracing::error!(error = %self, "media storage error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) + } + }; + (status, axum::Json(serde_json::json!({"error": msg}))).into_response() + } +} diff --git a/crates/sprout-media/src/lib.rs b/crates/sprout-media/src/lib.rs new file mode 100644 index 000000000..a0ff93c24 --- /dev/null +++ b/crates/sprout-media/src/lib.rs @@ -0,0 +1,18 @@ +//! Media storage, validation, and thumbnail generation for Sprout. +//! +//! Library crate — no Axum dependency for handlers. Axum handlers live in `sprout-relay`. + +pub mod auth; +pub mod config; +pub mod error; +pub mod storage; +pub mod thumbnail; +pub mod types; +pub mod upload; +pub mod validation; + +pub use config::MediaConfig; +pub use error::MediaError; +pub use storage::{BlobHeadMeta, BlobMeta, MediaStorage}; +pub use types::BlobDescriptor; +pub use upload::process_upload; diff --git a/crates/sprout-media/src/storage.rs b/crates/sprout-media/src/storage.rs new file mode 100644 index 000000000..729f40bc8 --- /dev/null +++ b/crates/sprout-media/src/storage.rs @@ -0,0 +1,120 @@ +//! S3/MinIO storage client. + +use s3::creds::Credentials; +use s3::{Bucket, Region}; +use serde::{Deserialize, Serialize}; + +use crate::config::MediaConfig; +use crate::error::MediaError; + +/// S3-compatible object storage client. +pub struct MediaStorage { + bucket: Box, +} + +impl MediaStorage { + /// Create a new storage client from config. + pub fn new(config: &MediaConfig) -> Result { + let region = Region::Custom { + region: "us-east-1".into(), + endpoint: config.s3_endpoint.clone(), + }; + let creds = Credentials::new( + Some(&config.s3_access_key), + Some(&config.s3_secret_key), + None, + None, + None, + ) + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let bucket = Bucket::new(&config.s3_bucket, region, creds) + .map_err(|e| MediaError::StorageError(e.to_string()))? + .with_path_style(); + Ok(Self { bucket }) + } + + /// Store an object. + pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> { + self.bucket + .put_object_with_content_type(key, bytes, content_type) + .await?; + Ok(()) + } + + /// Retrieve an object's bytes. + pub async fn get(&self, key: &str) -> Result, MediaError> { + match self.bucket.get_object(key).await { + Ok(response) => Ok(response.to_vec()), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound), + Err(e) => Err(MediaError::StorageError(e.to_string())), + } + } + + /// Check if an object exists. Returns false on 404. + pub async fn head(&self, key: &str) -> Result { + match self.bucket.head_object(key).await { + Ok(_) => Ok(true), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false), + Err(e) => Err(MediaError::StorageError(e.to_string())), + } + } + + /// Delete an object. Returns an error on failure — callers decide whether to propagate. + pub async fn delete(&self, key: &str) -> Result<(), MediaError> { + self.bucket + .delete_object(key) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + Ok(()) + } + + /// HEAD with metadata — returns Content-Length (size). + pub async fn head_with_metadata(&self, key: &str) -> Result, MediaError> { + match self.bucket.head_object(key).await { + Ok((result, _)) => Ok(Some(BlobHeadMeta { + size: result.content_length.unwrap_or(0) as u64, + })), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None), + Err(e) => Err(MediaError::StorageError(e.to_string())), + } + } + + /// Read sidecar JSON for a given sha256 (bare hash, no extension). + pub async fn get_sidecar(&self, sha256: &str) -> Result { + let key = format!("_meta/{sha256}.json"); + let resp = self.bucket.get_object(&key).await?; + let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?; + Ok(meta) + } + + /// Convenience: read just the MIME type from the sidecar. + pub async fn read_sidecar_mime(&self, sha256_ext: &str) -> Option { + let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); + self.get_sidecar(sha256).await.ok().map(|m| m.mime_type) + } +} + +/// Metadata returned by HEAD — just enough for BUD-01 response headers. +pub struct BlobHeadMeta { + pub size: u64, +} + +/// Full blob metadata — stored as sidecar JSON in `_meta/{sha256}.json`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BlobMeta { + /// Pixel dimensions ("WxH"). + pub dim: String, + /// Blurhash string. + pub blurhash: String, + /// Full URL to thumbnail. + pub thumb_url: String, + /// File extension (e.g. "jpg"). + pub ext: String, + /// MIME type (e.g. "image/jpeg"). + pub mime_type: String, + /// File size in bytes. + pub size: u64, + /// Unix timestamp when the blob was first uploaded. + #[serde(default)] + pub uploaded_at: i64, +} diff --git a/crates/sprout-media/src/thumbnail.rs b/crates/sprout-media/src/thumbnail.rs new file mode 100644 index 000000000..580c4b9f9 --- /dev/null +++ b/crates/sprout-media/src/thumbnail.rs @@ -0,0 +1,51 @@ +//! Synchronous thumbnail generation and blurhash encoding. + +use std::io::Cursor; + +use image::ImageFormat; + +use crate::config::MediaConfig; +use crate::error::MediaError; +use crate::storage::BlobMeta; + +/// Generate thumbnail and blurhash from image bytes (CPU-bound, sync). +/// +/// Returns `(metadata, optional thumbnail JPEG bytes)`. +/// Caller handles S3 writes after `spawn_blocking` returns. +pub fn generate_image_metadata_sync( + config: &MediaConfig, + sha256: &str, + bytes: &[u8], + mime: &str, + ext: &str, +) -> Result<(BlobMeta, Option>), MediaError> { + if !mime.starts_with("image/") { + return Ok((BlobMeta::default(), None)); + } + + let img = image::load_from_memory(bytes)?; + let (w, h) = (img.width(), img.height()); + + // Thumbnail: 320px max dimension, preserve aspect ratio + let thumb = img.thumbnail(320, 320); + let mut thumb_bytes = Vec::new(); + thumb.write_to(&mut Cursor::new(&mut thumb_bytes), ImageFormat::Jpeg)?; + + // Blurhash from thumbnail (faster than full image) + let rgba = thumb.to_rgba8(); + let bh = + blurhash::encode(4, 3, thumb.width(), thumb.height(), rgba.as_raw()).unwrap_or_default(); + + Ok(( + BlobMeta { + dim: format!("{w}x{h}"), + blurhash: bh, + thumb_url: format!("{}/{sha256}.thumb.jpg", config.public_base_url), + ext: ext.to_string(), + mime_type: mime.to_string(), + size: bytes.len() as u64, + ..BlobMeta::default() // uploaded_at set by caller (process_upload) + }, + Some(thumb_bytes), + )) +} diff --git a/crates/sprout-media/src/types.rs b/crates/sprout-media/src/types.rs new file mode 100644 index 000000000..7045b64d0 --- /dev/null +++ b/crates/sprout-media/src/types.rs @@ -0,0 +1,28 @@ +//! Blossom BUD-02 response types. + +use serde::{Deserialize, Serialize}; + +/// Blossom BlobDescriptor — returned by PUT /media/upload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobDescriptor { + /// Full URL to the blob. + pub url: String, + /// SHA-256 hex hash (64 chars). + pub sha256: String, + /// File size in bytes. + pub size: u64, + /// MIME type. + #[serde(rename = "type")] + pub mime_type: String, + /// Unix timestamp of upload. + pub uploaded: i64, + /// Pixel dimensions ("WxH"). + #[serde(skip_serializing_if = "Option::is_none")] + pub dim: Option, + /// Blurhash string. + #[serde(skip_serializing_if = "Option::is_none")] + pub blurhash: Option, + /// Thumbnail URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, +} diff --git a/crates/sprout-media/src/upload.rs b/crates/sprout-media/src/upload.rs new file mode 100644 index 000000000..723d8f452 --- /dev/null +++ b/crates/sprout-media/src/upload.rs @@ -0,0 +1,142 @@ +//! Upload pipeline — validate, store, thumbnail, sidecar. + +use bytes::Bytes; +use sha2::{Digest, Sha256}; + +use crate::auth::verify_blossom_upload_auth; +use crate::config::MediaConfig; +use crate::error::MediaError; +use crate::storage::{BlobMeta, MediaStorage}; +use crate::thumbnail::generate_image_metadata_sync; +use crate::types::BlobDescriptor; +use crate::validation::{mime_to_ext, validate_content}; + +/// Process an upload end-to-end: validate, store, thumbnail, return descriptor. +pub async fn process_upload( + storage: &MediaStorage, + config: &MediaConfig, + auth_event: &nostr::Event, + body: Bytes, +) -> Result { + // CPU-bound: validate content, compute hash, verify auth + let auth = auth_event.clone(); + let bytes = body.clone(); + let cfg = config.clone(); + let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> { + let mime = validate_content(&bytes, &cfg)?; + let sha256 = hex::encode(Sha256::digest(&bytes)); + let ext = mime_to_ext(&mime).to_string(); + verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref())?; + Ok((mime, sha256, ext)) + }) + .await + .map_err(|_| MediaError::Internal)??; + + let key = format!("{sha256}.{ext}"); + let meta_key = format!("_meta/{sha256}.json"); // used in idempotency check below + + // Idempotent: check BOTH sidecar AND blob exist before short-circuiting. + // If sidecar exists but blob is missing, fall through to re-upload. + let sidecar_exists = storage.head(&meta_key).await?; + let blob_exists = storage.head(&key).await?; + if sidecar_exists && blob_exists { + let meta = storage.get_sidecar(&sha256).await?; + return Ok(build_descriptor( + config, + &sha256, + &ext, + &mime, + body.len() as u64, + Some(&meta), + meta.uploaded_at, + )); + } + + // Compute uploaded_at once — single source of truth for sidecar and response. + let uploaded_at = chrono::Utc::now().timestamp(); + + // Store blob first, then generate metadata. + // On failure we intentionally do NOT delete the orphan blob — concurrent + // uploads of the same hash could race and delete a blob that another + // request is about to reference via its sidecar. Orphan blobs are + // content-addressed and bounded by the upload size limit, so the storage + // cost is negligible. A V2 background GC job can sweep blobs with no + // matching sidecar after a grace period. + storage.put(&key, &body, &mime).await?; + + match generate_and_store_metadata(storage, config, &sha256, &ext, &mime, &body, uploaded_at) + .await + { + Ok(meta) => Ok(build_descriptor( + config, + &sha256, + &ext, + &mime, + body.len() as u64, + Some(&meta), + uploaded_at, + )), + Err(e) => { + tracing::warn!(sha256 = %sha256, "metadata generation failed; orphan blob left for GC"); + Err(e) + } + } +} + +/// Generate thumbnail, blurhash, and sidecar metadata, then store them. +/// Returns the completed [`BlobMeta`] on success. +async fn generate_and_store_metadata( + storage: &MediaStorage, + config: &MediaConfig, + sha256: &str, + ext: &str, + mime: &str, + body: &Bytes, + uploaded_at: i64, +) -> Result { + let body_ref = body.clone(); + let mime_ref = mime.to_string(); + let ext_ref = ext.to_string(); + let sha256_ref = sha256.to_string(); + let cfg_ref = config.clone(); + let (mut meta, thumb_bytes) = tokio::task::spawn_blocking(move || { + generate_image_metadata_sync(&cfg_ref, &sha256_ref, &body_ref, &mime_ref, &ext_ref) + }) + .await + .map_err(|_| MediaError::Internal)??; + + meta.uploaded_at = uploaded_at; + + if let Some(ref tb) = thumb_bytes { + let thumb_key = format!("{sha256}.thumb.jpg"); + storage.put(&thumb_key, tb, "image/jpeg").await?; + } + + let meta_key = format!("_meta/{sha256}.json"); + let meta_json = serde_json::to_vec(&meta)?; + storage + .put(&meta_key, &meta_json, "application/json") + .await?; + Ok(meta) +} + +fn build_descriptor( + config: &MediaConfig, + sha256: &str, + ext: &str, + mime: &str, + size: u64, + meta: Option<&BlobMeta>, + uploaded_at: i64, +) -> BlobDescriptor { + BlobDescriptor { + url: format!("{}/{sha256}.{ext}", config.public_base_url), + sha256: sha256.to_string(), + size, + mime_type: mime.to_string(), + uploaded: uploaded_at, + dim: meta.map(|m| m.dim.clone()), + blurhash: meta.map(|m| m.blurhash.clone()), + thumb: meta.map(|m| m.thumb_url.clone()), + } +} diff --git a/crates/sprout-media/src/validation.rs b/crates/sprout-media/src/validation.rs new file mode 100644 index 000000000..e6784fe50 --- /dev/null +++ b/crates/sprout-media/src/validation.rs @@ -0,0 +1,166 @@ +//! Content validation — magic bytes, allowlist, size, image bomb protection. + +use crate::config::MediaConfig; +use crate::error::MediaError; + +/// V1: images only. Video deferred to v2. +const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; + +/// Validate uploaded bytes: magic bytes, allowlist, size, pixel dimensions. +pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result { + // 1. Magic bytes — never trust Content-Type header + let mime = infer::get(bytes) + .map(|t| t.mime_type().to_string()) + .ok_or(MediaError::UnknownContentType)?; + + // 2. Allowlist (SVG, PDF, executables all rejected) + if !ALLOWED_MIME_TYPES.contains(&mime.as_str()) { + return Err(MediaError::DisallowedContentType(mime)); + } + + // 3. Size — GIF-specific cap (animated GIFs are CPU-intensive) + let max = if mime == "image/gif" { + config.max_gif_bytes + } else { + config.max_image_bytes + }; + if bytes.len() as u64 > max { + return Err(MediaError::FileTooLarge { + size: bytes.len() as u64, + max, + }); + } + + // 4. Image bomb — check pixel dimensions before full decode. + // Fail closed for all accepted types: imagesize supports JPEG, PNG, GIF, WebP. + // If dimensions can't be parsed, reject — don't let unknown-geometry images + // reach the full decoder in thumbnail generation. + const MAX_PIXELS: u64 = 25_000_000; // 25 megapixels — 100MB max RGBA decode + let size = imagesize::blob_size(bytes).map_err(|_| MediaError::InvalidImage)?; + if (size.width as u64) * (size.height as u64) > MAX_PIXELS { + return Err(MediaError::ImageTooLarge); + } + + Ok(mime) +} + +/// Map MIME type to file extension. +pub fn mime_to_ext(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "bin", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> MediaConfig { + MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + public_base_url: String::new(), + server_domain: None, + } + } + + // Minimal valid JPEG: SOI + APP0 + SOF0 (1x1px). + // SOF0 is required for imagesize to parse dimensions (fail-closed check). + const TINY_JPEG: &[u8] = &[ + // SOI + 0xFF, 0xD8, // APP0 (JFIF marker) + 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, + 0x01, 0x00, 0x00, // SOF0: precision=8, height=1, width=1, components=1 + 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, + // EOI + 0xFF, 0xD9, + ]; + + // Minimal PNG header + const TINY_PNG: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + ]; + + #[test] + fn test_validate_jpeg() { + let config = test_config(); + let result = validate_content(TINY_JPEG, &config); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "image/jpeg"); + } + + #[test] + fn test_validate_png() { + let config = test_config(); + let result = validate_content(TINY_PNG, &config); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "image/png"); + } + + #[test] + fn test_validate_svg_rejected() { + let config = test_config(); + // SVG starts with XML declaration — infer won't detect it as image + let svg = b""; + let result = validate_content(svg, &config); + assert!(result.is_err()); + } + + #[test] + fn test_validate_oversized() { + let mut config = test_config(); + config.max_image_bytes = 10; // 10 bytes max + let result = validate_content(TINY_JPEG, &config); + assert!(matches!(result, Err(MediaError::FileTooLarge { .. }))); + } + + // Minimal valid GIF89a (1x1 pixel) — full logical screen descriptor so imagesize can parse. + const TINY_GIF: &[u8] = &[ + // Header + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + // Logical Screen Descriptor: width=1, height=1, flags, bgcolor, aspect + 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, + // Global Color Table (2 colors: white, black) + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Image Descriptor + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Data + 0x02, 0x02, 0x4C, 0x01, 0x00, // Trailer + 0x3B, + ]; + + #[test] + fn test_validate_gif_cap() { + let mut config = test_config(); + config.max_gif_bytes = 5; // tiny cap + config.max_image_bytes = 50 * 1024 * 1024; + let result = validate_content(TINY_GIF, &config); + assert!(matches!(result, Err(MediaError::FileTooLarge { .. }))); + } + + #[test] + fn test_validate_gif_ok() { + let config = test_config(); + let result = validate_content(TINY_GIF, &config); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "image/gif"); + } + + #[test] + fn test_mime_to_ext() { + assert_eq!(mime_to_ext("image/jpeg"), "jpg"); + assert_eq!(mime_to_ext("image/png"), "png"); + assert_eq!(mime_to_ext("image/gif"), "gif"); + assert_eq!(mime_to_ext("image/webp"), "webp"); + assert_eq!(mime_to_ext("application/pdf"), "bin"); + } +} diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index 1a40a1960..34957eeff 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -39,6 +39,9 @@ redis = { workspace = true } sqlx = { workspace = true } base64 = "0.22" sprout-workflow = { workspace = true, features = ["reqwest"] } +sprout-media = { workspace = true } +bytes = "1" +infer = "0.16" serde_yaml = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } diff --git a/crates/sprout-relay/src/api/media.rs b/crates/sprout-relay/src/api/media.rs new file mode 100644 index 000000000..5107294f3 --- /dev/null +++ b/crates/sprout-relay/src/api/media.rs @@ -0,0 +1,498 @@ +//! Blossom-compatible media upload, retrieval, and existence check handlers. +//! +//! Routes: +//! PUT /media/upload — BUD-02 upload (auth required) +//! GET /media/{sha256_ext} — BUD-01 serve blob +//! HEAD /media/{sha256_ext} — BUD-01 existence check + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::{FromRequestParts, Path, State}, + http::{request::Parts, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use sprout_audit::{AuditAction, NewAuditEntry}; +use sprout_auth::Scope; +use sprout_media::{BlobDescriptor, MediaError}; + +use crate::state::AppState; + +// ── Upload ──────────────────────────────────────────────────────────────────── + +/// Axum extractor that validates Blossom auth + API token scopes from headers +/// BEFORE the request body is read. This prevents unauthenticated clients from +/// forcing the server to buffer up to 50MB of body data. +/// +/// Axum processes `FromRequestParts` extractors before `FromRequest` (body) +/// extractors, so auth rejection happens before any body buffering. +pub(crate) struct AuthenticatedUpload { + auth_event: nostr::Event, + #[allow(dead_code)] // scopes validated in extractor; stored for future per-scope handler logic + scopes: Vec, +} + +impl FromRequestParts> for AuthenticatedUpload { + type Rejection = MediaError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let headers = &parts.headers; + + // 1. Extract and validate Blossom auth event + let auth_event = extract_blossom_auth(headers)?; + sprout_media::auth::verify_blossom_auth_event( + &auth_event, + state.config.media.server_domain.as_deref(), + )?; + + // 2. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload) + let claimed_hash = headers + .get("x-sha-256") + .and_then(|v| v.to_str().ok()) + .ok_or(MediaError::MissingTag("x-sha-256"))?; + + // Validate format: exactly 64 lowercase hex characters + if claimed_hash.len() != 64 + || !claimed_hash + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return Err(MediaError::HashMismatch); + } + + // 3. Validate X-SHA-256 matches at least one x tag in the auth event + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash))); + if !has_matching_x { + return Err(MediaError::HashMismatch); + } + + // 4. Resolve scopes (API token or dev mode) + let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?; + sprout_auth::require_scope(&scopes, Scope::FilesWrite) + .map_err(|_| MediaError::InsufficientScope)?; + + Ok(AuthenticatedUpload { auth_event, scopes }) + } +} + +/// PUT /media/upload — Blossom BUD-02 upload. +/// +/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body +/// is read, preventing unauthenticated clients from forcing body buffering. +// AuthenticatedUpload is pub(crate) — it's an internal extractor type, never +// exposed outside this crate. The warning is benign: axum resolves it at +// compile time via trait bounds, not by name. +#[allow(private_interfaces)] +/// +/// Expects: +/// - `Authorization: Nostr ` — Blossom auth +/// - `X-SHA-256: ` — Required per BUD-11 +/// - `X-Auth-Token: sprout_*` — API token for scope resolution (optional in dev mode) +/// - Raw binary body (the file bytes) +/// +/// Returns a [`BlobDescriptor`] JSON on success. +// TODO(v2): Add per-pubkey upload rate limiting and storage quotas to prevent +// bandwidth/storage exhaustion from authenticated callers. Currently mitigated by +// auth requirement (API token + Blossom signature) and body size limit. +pub async fn upload_blob( + State(state): State>, + auth: AuthenticatedUpload, + body: Bytes, +) -> Result, MediaError> { + let descriptor = sprout_media::process_upload( + &state.media_storage, + &state.config.media, + &auth.auth_event, + body, + ) + .await?; + + // Fire-and-forget audit — never block the response on audit I/O. + let audit = state.audit.clone(); + let desc = descriptor.clone(); + let uploader = auth.auth_event.pubkey.to_hex(); + tokio::spawn(async move { + let _ = audit + .log(NewAuditEntry { + event_id: desc.sha256.clone(), + event_kind: sprout_core::kind::KIND_MEDIA_UPLOAD, + actor_pubkey: uploader, + action: AuditAction::MediaUploaded, + channel_id: None, + metadata: serde_json::json!({ + "sha256": desc.sha256, + "size": desc.size, + "mime": desc.mime_type, + }), + }) + .await; + }); + + Ok(Json(descriptor)) +} + +// ── Serve ───────────────────────────────────────────────────────────────────── + +/// Validate that sha256_ext is a safe path segment. +/// +/// Accepted forms (max 3 segments): +/// - `{sha256}` — bare 64-char lowercase hex +/// - `{sha256}.{ext}` — hash + extension +/// - `{sha256}.thumb.jpg` — hash + thumb variant (always JPEG) +/// +/// Where `{ext}` ∈ {"jpg", "png", "gif", "webp"} for primary blobs (uploads canonicalize to .jpg, not .jpeg). +/// Rejects path traversal, leading underscores, and any non-hex first segment. +fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> { + const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + + let segments: Vec<&str> = sha256_ext.split('.').collect(); + + // 1–3 segments only (hash, optional thumb, optional ext) + if segments.is_empty() || segments.len() > 3 { + return Err(MediaError::NotFound); + } + + // First segment must be exactly 64 lowercase hex chars (SHA-256) + let hash = segments[0]; + if hash.len() != 64 || !hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err(MediaError::NotFound); + } + + // Validate remaining segments + match segments.len() { + 1 => {} // bare hash — ok + 2 => { + // {hash}.{ext} + if !ALLOWED_EXTS.contains(&segments[1]) { + return Err(MediaError::NotFound); + } + } + 3 => { + // {hash}.thumb.jpg — thumbnails are always JPEG + if segments[1] != "thumb" || segments[2] != "jpg" { + return Err(MediaError::NotFound); + } + } + _ => return Err(MediaError::NotFound), + } + + Ok(()) +} + +/// GET /media/{sha256_ext} — Blossom BUD-01 serve blob. +/// +/// `sha256_ext` is either: +/// - `.` — direct key (e.g. `abc123.jpg`) +/// - `` — bare hash; extension resolved from sidecar +/// - `.thumb.jpg` — thumbnail variant +pub async fn get_blob( + State(state): State>, + Path(sha256_ext): Path, +) -> Result { + validate_media_path(&sha256_ext)?; + + // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. + let content_type = if sha256_ext.ends_with(".thumb.jpg") { + let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext); + let _ = state + .media_storage + .read_sidecar_mime(parent_hash) + .await + .ok_or(MediaError::NotFound)?; + "image/jpeg".to_string() + } else { + // For explicit paths (hash.ext), verify the requested extension matches + // the sidecar's canonical extension — sidecar is authoritative. + let sidecar_mime = state + .media_storage + .read_sidecar_mime(&sha256_ext) + .await + .ok_or(MediaError::NotFound)?; + if sha256_ext.contains('.') { + let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); + let sidecar = state + .media_storage + .get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext)) + .await + .map_err(|_| MediaError::NotFound)?; + if requested_ext != sidecar.ext { + return Err(MediaError::NotFound); + } + } + sidecar_mime + }; + + let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?; + let bytes = state.media_storage.get(&key).await?; + + Ok(( + [ + ("content-type", content_type.as_str()), + ("cache-control", "public, max-age=31536000, immutable"), + ("content-disposition", "inline"), + ("content-security-policy", "default-src 'none'"), + ("x-content-type-options", "nosniff"), + ], + bytes, + ) + .into_response()) +} + +/// HEAD /media/{sha256_ext} — Blossom BUD-01 existence check. +/// +/// Content-type is derived from the validated sidecar only — never from raw S3 +/// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar +/// is missing, we return 404 rather than fall back to untrusted metadata. +pub async fn head_blob( + State(state): State>, + Path(sha256_ext): Path, +) -> Result { + validate_media_path(&sha256_ext)?; + + // Sidecar gate FIRST — reject before any blob I/O. + let content_type = if sha256_ext.ends_with(".thumb.jpg") { + let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext); + let _ = state + .media_storage + .read_sidecar_mime(parent_hash) + .await + .ok_or(MediaError::NotFound)?; + "image/jpeg".to_string() + } else { + let sidecar_mime = state + .media_storage + .read_sidecar_mime(&sha256_ext) + .await + .ok_or(MediaError::NotFound)?; + if sha256_ext.contains('.') { + let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); + let sidecar = state + .media_storage + .get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext)) + .await + .map_err(|_| MediaError::NotFound)?; + if requested_ext != sidecar.ext { + return Err(MediaError::NotFound); + } + } + sidecar_mime + }; + + let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?; + match state.media_storage.head_with_metadata(&key).await? { + Some(meta) => { + let size_str = meta.size.to_string(); + Ok(( + StatusCode::OK, + [ + ("content-type", content_type.as_str()), + ("content-length", size_str.as_str()), + ("cache-control", "public, max-age=31536000, immutable"), + ], + ) + .into_response()) + } + None => Ok(StatusCode::NOT_FOUND.into_response()), + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Resolve the S3 key from a URL path segment. +/// +/// - `sha256.ext` → used as-is (already validated by `validate_media_path`) +/// - `sha256` (no dot) → read sidecar to get extension, return `sha256.ext` +/// +/// Sidecar-derived extensions are validated against the allowlist to prevent +/// object-key confusion if sidecar data is ever tampered with. +async fn resolve_s3_key( + storage: &sprout_media::MediaStorage, + sha256_ext: &str, +) -> Result { + const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + + if sha256_ext.contains('.') { + Ok(sha256_ext.to_string()) + } else { + let sidecar = storage + .get_sidecar(sha256_ext) + .await + .map_err(|_| MediaError::NotFound)?; + // Validate sidecar ext — never trust storage as authoritative for path construction + if !ALLOWED_EXTS.contains(&sidecar.ext.as_str()) { + return Err(MediaError::NotFound); + } + Ok(format!("{}.{}", sha256_ext, sidecar.ext)) + } +} + +/// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header. +/// +/// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat). +fn extract_blossom_auth(headers: &HeaderMap) -> Result { + use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; + + let header = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(MediaError::MissingAuth)?; + + let token = header + .strip_prefix("Nostr ") + .ok_or(MediaError::InvalidAuthScheme)?; + + let json_bytes = URL_SAFE_NO_PAD + .decode(token) + .or_else(|_| STANDARD.decode(token)) + .map_err(|_| MediaError::InvalidBase64)?; + + let event: nostr::Event = + serde_json::from_slice(&json_bytes).map_err(|_| MediaError::InvalidAuthEvent)?; + + Ok(event) +} + +/// Resolve permission scopes for an upload caller. +/// +/// Resolution order: +/// 1. `X-Auth-Token: sprout_*` header — API token path (validates owner matches Blossom signer) +/// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes +async fn resolve_upload_scopes( + headers: &HeaderMap, + state: &AppState, + blossom_pubkey: &nostr::PublicKey, +) -> Result, MediaError> { + // 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token. + if let Some(token) = headers + .get("x-auth-token") + .and_then(|v| v.to_str().ok()) + .filter(|t| t.starts_with("sprout_")) + { + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + let record = state + .db + .get_api_token_by_hash_including_revoked(&hash) + .await + .map_err(|_| MediaError::Unauthorized)? + .ok_or(MediaError::Unauthorized)?; + + if record.revoked_at.is_some() { + return Err(MediaError::TokenRevoked); + } + if let Some(expires_at) = record.expires_at { + if expires_at < chrono::Utc::now() { + return Err(MediaError::TokenExpired); + } + } + + // Token owner must match the Blossom signer — prevents token theft attacks. + let blossom_bytes = blossom_pubkey.serialize().to_vec(); + if record.owner_pubkey != blossom_bytes { + return Err(MediaError::PubkeyMismatch); + } + + return Ok(record + .scopes + .iter() + .filter_map(|s| s.parse::().ok()) + .collect()); + } + + // 2. Dev mode: no API token required. + if state.config.require_auth_token { + return Err(MediaError::Unauthorized); + } + + // Dev mode is active — any valid Blossom signer can upload. + // This must never be enabled in production. + tracing::warn!( + "dev mode upload: no API token required — ensure require_auth_token=true in production" + ); + + // 3. Pubkey allowlist check (dev mode only). + if state.config.pubkey_allowlist_enabled { + let pubkey_bytes = blossom_pubkey.serialize().to_vec(); + if !state + .db + .is_pubkey_allowed(&pubkey_bytes) + .await + .unwrap_or(false) + { + return Err(MediaError::Unauthorized); + } + } + + // Dev mode: grant file scopes. + Ok(vec![Scope::FilesRead, Scope::FilesWrite]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + #[test] + fn test_validate_media_path_bare_hash() { + assert!(validate_media_path(VALID_HASH).is_ok()); + } + + #[test] + fn test_validate_media_path_hash_ext() { + for ext in &["jpg", "png", "gif", "webp"] { + assert!(validate_media_path(&format!("{VALID_HASH}.{ext}")).is_ok()); + } + } + + #[test] + fn test_validate_media_path_thumb_jpg_only() { + assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg")).is_ok()); + // Other thumb extensions rejected — thumbnails are always JPEG + assert!(validate_media_path(&format!("{VALID_HASH}.thumb.png")).is_err()); + assert!(validate_media_path(&format!("{VALID_HASH}.thumb.webp")).is_err()); + } + + #[test] + fn test_validate_media_path_rejects_bad_ext() { + assert!(validate_media_path(&format!("{VALID_HASH}.svg")).is_err()); + assert!(validate_media_path(&format!("{VALID_HASH}.exe")).is_err()); + assert!(validate_media_path(&format!("{VALID_HASH}.pdf")).is_err()); + } + + #[test] + fn test_validate_media_path_rejects_short_hash() { + assert!(validate_media_path("abc123.jpg").is_err()); + } + + #[test] + fn test_validate_media_path_rejects_uppercase_hash() { + let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + assert!(validate_media_path(&format!("{upper}.jpg")).is_err()); + } + + #[test] + fn test_validate_media_path_rejects_traversal() { + assert!(validate_media_path("../etc/passwd").is_err()); + assert!(validate_media_path(&format!("../{VALID_HASH}.jpg")).is_err()); + } + + #[test] + fn test_validate_media_path_rejects_too_many_segments() { + assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg.extra")).is_err()); + } + + #[test] + fn test_validate_media_path_rejects_empty() { + assert!(validate_media_path("").is_err()); + } +} diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index c0b9c66ee..0c62fc957 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -35,6 +35,295 @@ use super::{ internal_error, not_found, }; +/// Validate imeta tags for correctness and safety. +/// +/// Shared between REST (send_message) and WebSocket (handle_event) paths. +/// Returns Ok(()) if all tags are valid, or a human-readable error string. +pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result<(), String> { + const ALLOWED_IMETA_KEYS: &[&str] = &[ + "url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", + ]; + const SINGLETON_KEYS: &[&str] = &["url", "m", "x", "size", "dim", "blurhash", "thumb", "alt"]; + const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; + + for tag in tags { + if tag.first().map(|s| s.as_str()) != Some("imeta") { + return Err("only imeta tags allowed in media_tags".into()); + } + + let mut has_url = false; + let mut has_m = false; + let mut has_x = false; + let mut has_size = false; + let mut seen_keys = std::collections::HashSet::new(); + let mut url_value = String::new(); + let mut x_value = String::new(); + let mut m_value = String::new(); + let mut thumb_value = String::new(); + + for part in tag.iter().skip(1) { + let mut parts = part.splitn(2, ' '); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + + if !ALLOWED_IMETA_KEYS.contains(&key) { + return Err(format!("disallowed imeta key: {key}")); + } + if SINGLETON_KEYS.contains(&key) && !seen_keys.insert(key.to_string()) { + return Err(format!("duplicate imeta key: {key}")); + } + + match key { + "url" => { + if !is_local_media_url(value, media_base_url) { + return Err("imeta url must be a local /media/ path".into()); + } + if value.contains(".thumb.") { + return Err( + "imeta url must not be a thumbnail path; use thumb field".into() + ); + } + url_value = value.to_string(); + has_url = true; + } + "m" => { + if !ALLOWED_MIME.contains(&value) { + return Err( + "imeta m must be image/jpeg, image/png, image/gif, or image/webp" + .into(), + ); + } + m_value = value.to_string(); + has_m = true; + } + "x" => { + if value.len() != 64 + || !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return Err("imeta x must be a 64-char lowercase hex SHA-256".into()); + } + x_value = value.to_string(); + has_x = true; + } + "size" => { + match value.parse::() { + Ok(0) | Err(_) => { + return Err("imeta size must be a positive integer".into()) + } + Ok(_) => {} + } + has_size = true; + } + "thumb" => { + if !is_local_media_url(value, media_base_url) || !value.ends_with(".thumb.jpg") + { + return Err("imeta thumb must be a local .thumb.jpg path".into()); + } + thumb_value = value.to_string(); + } + _ => {} + } + } + + if !has_url || !has_m || !has_x || !has_size { + return Err("imeta tag must include url, m, x, and size".into()); + } + + // Cross-check internal consistency: url hash must match x, url ext must match m. + if let Some(hash_in_url) = extract_hash_from_media_url(&url_value) { + if hash_in_url != x_value { + return Err("imeta url hash does not match x".into()); + } + } + if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) { + let expected_ext = mime_to_canonical_ext(&m_value); + if ext_in_url != expected_ext { + return Err("imeta url extension does not match m".into()); + } + } + // Thumb hash must match x (same blob, different variant). + if !thumb_value.is_empty() { + if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) { + if thumb_hash != x_value { + return Err("imeta thumb hash does not match x".into()); + } + } + } + } + Ok(()) +} + +/// Verify that every imeta tag references a blob that actually exists in storage +/// and that the claimed metadata (size, MIME) matches the sidecar. +/// +/// Called after syntactic validation. Returns Ok(()) if all blobs exist and match, +/// or a human-readable error string. This prevents clients from referencing +/// nonexistent blobs or lying about size/MIME in imeta tags. +pub async fn verify_imeta_blobs( + tags: &[Vec], + storage: &sprout_media::MediaStorage, +) -> Result<(), String> { + for tag in tags { + let mut x_value = String::new(); + let mut m_value = String::new(); + let mut size_value: u64 = 0; + let mut thumb_value = String::new(); + + for part in tag.iter().skip(1) { + let mut parts = part.splitn(2, ' '); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + match key { + "x" => x_value = value.to_string(), + "m" => m_value = value.to_string(), + "size" => size_value = value.parse().unwrap_or(0), + "thumb" => thumb_value = value.to_string(), + _ => {} + } + } + + if x_value.is_empty() { + continue; // syntactic validation already caught this + } + + // 1. Sidecar must exist — proves the upload pipeline completed. + let sidecar = storage + .get_sidecar(&x_value) + .await + .map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?; + + // 2. HEAD the actual blob object — sidecar alone is not proof of blob existence. + let blob_key = format!("{x_value}.{}", sidecar.ext); + let blob_exists = storage + .head(&blob_key) + .await + .map_err(|e| format!("storage error checking blob {x_value}: {e}"))?; + if !blob_exists { + return Err(format!("imeta blob object missing in storage: {x_value}")); + } + + // 3. Cross-check claimed metadata against sidecar. + if !m_value.is_empty() && sidecar.mime_type != m_value { + return Err(format!( + "imeta m ({m_value}) does not match stored MIME ({})", + sidecar.mime_type + )); + } + if size_value > 0 && sidecar.size != size_value { + return Err(format!( + "imeta size ({size_value}) does not match stored size ({})", + sidecar.size + )); + } + + // 4. If thumb is claimed, HEAD the thumbnail object too. + if !thumb_value.is_empty() { + let thumb_key = format!("{x_value}.thumb.jpg"); + let thumb_exists = storage + .head(&thumb_key) + .await + .map_err(|e| format!("storage error checking thumbnail: {e}"))?; + if !thumb_exists { + return Err(format!( + "imeta thumb references missing thumbnail: {x_value}" + )); + } + } + } + Ok(()) +} + +/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` or `/media/{hash}.thumb.jpg` URL. +fn extract_hash_from_media_url(url: &str) -> Option<&str> { + let after = url.rsplit("/media/").next()?; + let hash = after.split('.').next()?; + if hash.len() == 64 && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + Some(hash) + } else { + None + } +} + +/// Extract the primary extension from a `/media/{hash}.{ext}` URL (not thumb). +fn extract_ext_from_media_url(url: &str) -> Option<&str> { + let after = url.rsplit("/media/").next()?; + let segments: Vec<&str> = after.split('.').collect(); + if segments.len() == 2 { + Some(segments[1]) + } else { + None // bare hash or thumb — no primary ext to check + } +} + +/// Map MIME to canonical extension (must match sprout-media's mime_to_ext). +fn mime_to_canonical_ext(mime: &str) -> &str { + match mime { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "bin", + } +} + +/// Validate that a URL references a valid local media blob path. +/// +/// Accepts: +/// - Relative: `/media/.` or `/media/.thumb.jpg` +/// - Absolute: `/.` or `/.thumb.jpg` +/// +/// Where sha256 is exactly 64 lowercase hex chars and ext is an allowed image extension. +/// Thumbnails are always JPEG — only `.thumb.jpg` is accepted. +/// Rejects percent-encoded traversal, query strings, fragments, and external origins. +fn is_local_media_url(url: &str, media_base_url: &str) -> bool { + const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + + // Extract the path portion after /media/ + let path_after_media = if let Some(rest) = url.strip_prefix("/media/") { + rest + } else { + let base = media_base_url.trim_end_matches('/'); + let prefix = format!("{}/", base); + if let Some(rest) = url.strip_prefix(&prefix) { + rest + } else { + return false; + } + }; + + // Reject query strings and fragments + if path_after_media.contains('?') || path_after_media.contains('#') { + return false; + } + + // Reject percent-encoding (no legitimate blob path needs it) + if path_after_media.contains('%') { + return false; + } + + // Parse segments: must be {sha256}.{ext} or {sha256}.thumb.{ext} + let segments: Vec<&str> = path_after_media.split('.').collect(); + match segments.len() { + 2 => { + // {sha256}.{ext} + let hash = segments[0]; + let ext = segments[1]; + hash.len() == 64 + && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + && ALLOWED_EXTS.contains(&ext) + } + 3 => { + // {sha256}.thumb.jpg — thumbnails are always JPEG + let hash = segments[0]; + hash.len() == 64 + && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + && segments[1] == "thumb" + && segments[2] == "jpg" + } + _ => false, + } +} + /// Extract the effective message author from a stored event. /// /// REST-created messages are signed by the relay keypair and attribute the real @@ -241,6 +530,10 @@ pub struct SendMessageBody { pub content: String, /// Hex-encoded event ID of the parent message (for replies). pub parent_event_id: Option, + /// Hex-encoded pubkeys to mention in the message (added as `p` tags). + /// Distinct from the author-attribution `p` tag which is always added. + #[serde(default)] + pub mention_pubkeys: Vec, /// When `true`, a reply is also surfaced in the channel feed (broadcast). #[serde(default)] pub broadcast_to_channel: bool, @@ -269,6 +562,10 @@ pub struct SendMessageBody { pub diff_truncated: Option, /// Plain-text alternative summary for clients that cannot render diffs. pub diff_alt: Option, + /// Media attachment tags (imeta only). Whitelist-validated. + /// Each inner Vec is a single tag, e.g. `["imeta", "url https://...", "m image/jpeg"]`. + #[serde(default)] + pub media_tags: Vec>, } /// Send a new channel message or reply to an existing thread. @@ -302,7 +599,8 @@ pub async fn send_message( return Err(api_error(StatusCode::FORBIDDEN, "channel is archived")); } - if body.content.trim().is_empty() { + // Image-only messages: allow empty text content when media_tags are present. + if body.content.trim().is_empty() && body.media_tags.is_empty() { return Err(api_error(StatusCode::BAD_REQUEST, "content is required")); } @@ -409,6 +707,21 @@ pub async fn send_message( .map_err(|e| internal_error(&format!("tag build error: {e}")))?, ]; + // Mention p tags — distinct from the author-attribution p tag above. + for mention_hex in &body.mention_pubkeys { + // Validate hex format (32-byte pubkey = 64 hex chars) + if mention_hex.len() == 64 + && mention_hex + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + tags.push( + Tag::parse(&["p", mention_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + } + // Thread reply tags (NIP-10 style). if let (Some(ref root_bytes), Some(ref parent_bytes)) = (&root_id_bytes, &parent_id_bytes) { let root_hex = nostr_hex::encode(root_bytes); @@ -445,6 +758,21 @@ pub async fn send_message( tags.extend(diff_tags); } + // Validate imeta tags: syntactic checks, then verify blobs exist in storage. + validate_imeta_tags(&body.media_tags, &state.config.media.public_base_url) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?; + verify_imeta_blobs(&body.media_tags, &state.media_storage) + .await + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?; + + for tag in &body.media_tags { + tags.push( + Tag::parse(tag).map_err(|e| { + api_error(StatusCode::BAD_REQUEST, &format!("invalid imeta tag: {e}")) + })?, + ); + } + let event = EventBuilder::new(kind, &body.content, tags) .sign_with_keys(&state.relay_keypair) .map_err(|e| internal_error(&format!("event signing error: {e}")))?; @@ -884,3 +1212,158 @@ pub async fn get_thread( "next_cursor": next_cursor, }))) } + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const BASE: &str = "https://relay.example.com/media"; + + #[test] + fn test_local_media_url_relative() { + assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE)); + assert!(is_local_media_url(&format!("/media/{HASH}.png"), BASE)); + assert!(is_local_media_url(&format!("/media/{HASH}.gif"), BASE)); + assert!(is_local_media_url(&format!("/media/{HASH}.webp"), BASE)); + } + + #[test] + fn test_local_media_url_absolute() { + assert!(is_local_media_url(&format!("{BASE}/{HASH}.jpg"), BASE)); + } + + #[test] + fn test_local_media_url_thumb_jpg_only() { + assert!(is_local_media_url( + &format!("/media/{HASH}.thumb.jpg"), + BASE + )); + // Other thumb extensions rejected + assert!(!is_local_media_url( + &format!("/media/{HASH}.thumb.png"), + BASE + )); + assert!(!is_local_media_url( + &format!("/media/{HASH}.thumb.webp"), + BASE + )); + } + + #[test] + fn test_local_media_url_rejects_external() { + assert!(!is_local_media_url( + &format!("https://evil.com/media/{HASH}.jpg"), + BASE + )); + } + + #[test] + fn test_local_media_url_rejects_query_string() { + assert!(!is_local_media_url( + &format!("/media/{HASH}.jpg?foo=bar"), + BASE + )); + } + + #[test] + fn test_local_media_url_rejects_fragment() { + assert!(!is_local_media_url( + &format!("/media/{HASH}.jpg#frag"), + BASE + )); + } + + #[test] + fn test_local_media_url_rejects_percent_encoding() { + assert!(!is_local_media_url(&format!("/media/{HASH}%2e.jpg"), BASE)); + assert!(!is_local_media_url("/media/%2e%2e/etc/passwd", BASE)); + } + + #[test] + fn test_local_media_url_rejects_uppercase_hash() { + let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + assert!(!is_local_media_url(&format!("/media/{upper}.jpg"), BASE)); + } + + #[test] + fn test_local_media_url_rejects_short_hash() { + assert!(!is_local_media_url("/media/abc123.jpg", BASE)); + } + + #[test] + fn test_local_media_url_rejects_bad_ext() { + assert!(!is_local_media_url(&format!("/media/{HASH}.svg"), BASE)); + assert!(!is_local_media_url(&format!("/media/{HASH}.exe"), BASE)); + // .jpeg is not canonical — uploads produce .jpg only + assert!(!is_local_media_url(&format!("/media/{HASH}.jpeg"), BASE)); + } + + /// Thumb validation requires BOTH is_local_media_url AND .thumb.jpg suffix. + /// A full-size blob URL must not be accepted as a thumbnail. + #[test] + fn test_thumb_must_be_thumb_jpg() { + let thumb = format!("/media/{HASH}.thumb.jpg"); + let blob = format!("/media/{HASH}.jpg"); + assert!(is_local_media_url(&thumb, BASE) && thumb.ends_with(".thumb.jpg")); + assert!(is_local_media_url(&blob, BASE)); + assert!(!blob.ends_with(".thumb.jpg")); + } + + // ── imeta consistency cross-checks ────────────────────────────────────── + + #[test] + fn test_imeta_url_hash_must_match_x() { + let other = "b".repeat(64); + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.jpg"), + "m image/jpeg".into(), + format!("x {other}"), + "size 100".into(), + ]; + let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); + assert!(err.contains("url hash does not match x"), "{err}"); + } + + #[test] + fn test_imeta_url_ext_must_match_m() { + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.png"), + "m image/jpeg".into(), + format!("x {HASH}"), + "size 100".into(), + ]; + let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); + assert!(err.contains("url extension does not match m"), "{err}"); + } + + #[test] + fn test_imeta_thumb_hash_must_match_x() { + let other = "c".repeat(64); + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.jpg"), + "m image/jpeg".into(), + format!("x {HASH}"), + "size 100".into(), + format!("thumb /media/{other}.thumb.jpg"), + ]; + let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); + assert!(err.contains("thumb hash does not match x"), "{err}"); + } + + #[test] + fn test_imeta_consistent_tags_pass() { + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.jpg"), + "m image/jpeg".into(), + format!("x {HASH}"), + "size 100".into(), + format!("thumb /media/{HASH}.thumb.jpg"), + ]; + assert!(validate_imeta_tags(&[tag], BASE).is_ok()); + } +} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 80b5d0106..22f60586a 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -26,6 +26,8 @@ pub mod dms; pub mod events; /// Personalized home feed endpoint. pub mod feed; +/// Blossom-compatible media upload, retrieval, and existence check endpoints. +pub mod media; /// Channel membership endpoints. pub mod members; /// Message and thread endpoints. @@ -60,7 +62,10 @@ pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler}; pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; -pub use messages::{delete_message, get_thread, list_messages, send_message}; +pub use messages::{ + delete_message, get_thread, list_messages, send_message, validate_imeta_tags, + verify_imeta_blobs, +}; pub use presence::{presence_handler, set_presence_handler}; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; pub use search::search_handler; diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index a4d6f9548..327aff29b 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -53,6 +53,8 @@ pub struct Config { /// API tokens or Okta JWTs bypass the allowlist entirely. /// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`. pub pubkey_allowlist_enabled: bool, + /// Media storage configuration (S3/MinIO). + pub media: sprout_media::MediaConfig, } impl Config { @@ -135,6 +137,47 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + let media = sprout_media::MediaConfig { + s3_endpoint: std::env::var("SPROUT_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_string()), + s3_access_key: std::env::var("SPROUT_S3_ACCESS_KEY") + .unwrap_or_else(|_| "sprout_dev".to_string()), + s3_secret_key: std::env::var("SPROUT_S3_SECRET_KEY") + .unwrap_or_else(|_| "sprout_dev_secret".to_string()), + s3_bucket: std::env::var("SPROUT_S3_BUCKET") + .unwrap_or_else(|_| "sprout-media".to_string()), + max_image_bytes: std::env::var("SPROUT_MAX_IMAGE_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(50 * 1024 * 1024), + max_gif_bytes: std::env::var("SPROUT_MAX_GIF_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10 * 1024 * 1024), + public_base_url: std::env::var("SPROUT_MEDIA_BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000/media".to_string()), + server_domain: std::env::var("SPROUT_MEDIA_SERVER_DOMAIN") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| { + // Auto-derive from RELAY_URL so desktop uploads work out-of-the-box + // without requiring an extra env var in dev mode. + url::Url::parse( + &relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"), + ) + .ok() + .and_then(|u| { + let host = u.host_str()?.to_string(); + match u.port() { + Some(p) => Some(format!("{host}:{p}")), + None => Some(host), + } + }) + }), + }; + Ok(Self { bind_addr, database_url, @@ -151,6 +194,7 @@ impl Config { relay_private_key, uds_path, pubkey_allowlist_enabled, + media, }) } } @@ -187,4 +231,45 @@ mod tests { std::env::remove_var("SPROUT_BIND_ADDR"); assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_)))); } + + #[test] + fn server_domain_auto_derived_from_relay_url() { + let _guard = ENV_MUTEX.lock().unwrap(); + // Clear explicit override so auto-derive kicks in + std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN"); + std::env::set_var("RELAY_URL", "ws://localhost:3000"); + let config = Config::from_env().expect("config"); + std::env::remove_var("RELAY_URL"); + assert_eq!( + config.media.server_domain.as_deref(), + Some("localhost:3000") + ); + } + + #[test] + fn server_domain_auto_derived_default_port() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN"); + std::env::set_var("RELAY_URL", "wss://relay.example.com"); + let config = Config::from_env().expect("config"); + std::env::remove_var("RELAY_URL"); + assert_eq!( + config.media.server_domain.as_deref(), + Some("relay.example.com") + ); + } + + #[test] + fn server_domain_explicit_override_wins() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SPROUT_MEDIA_SERVER_DOMAIN", "custom.example.com"); + std::env::set_var("RELAY_URL", "ws://localhost:3000"); + let config = Config::from_env().expect("config"); + std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN"); + std::env::remove_var("RELAY_URL"); + assert_eq!( + config.media.server_domain.as_deref(), + Some("custom.example.com") + ); + } } diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 60dc7a30b..a50a5e654 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -360,6 +360,40 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc> = event + .tags + .iter() + .filter(|t| t.kind().to_string() == "imeta") + .map(|t| { + // Use as_slice() to capture ALL tag elements — no index cap. + // The first element is the tag kind ("imeta"), already included. + t.as_slice().iter().map(|s| s.to_string()).collect() + }) + .collect(); + if !imeta_tags.is_empty() { + if let Err(e) = + crate::api::validate_imeta_tags(&imeta_tags, &state.config.media.public_base_url) + { + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("invalid: {e}"), + )); + return; + } + // Verify referenced blobs exist in storage and metadata matches. + if let Err(e) = crate::api::verify_imeta_blobs(&imeta_tags, &state.media_storage).await { + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("invalid: {e}"), + )); + return; + } + } + let (stored_event, was_inserted) = match state.db.insert_event(&event, channel_id).await { Ok(result) => result, Err(sprout_db::DbError::AuthEventRejected) => { diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 0a4dd5a43..ee9c8c927 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -99,6 +99,14 @@ async fn main() -> anyhow::Result<()> { keys }; + config + .media + .validate() + .map_err(|e| anyhow::anyhow!("invalid media config: {e}"))?; + let media_storage = sprout_media::MediaStorage::new(&config.media) + .map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?; + info!("Media storage connected"); + let state = Arc::new(AppState::new( config.clone(), db, @@ -109,6 +117,7 @@ async fn main() -> anyhow::Result<()> { search, Arc::clone(&workflow_engine), relay_keypair, + media_storage, )); // Wire the action sink — must happen after AppState (which creates diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 77558f239..93d8a2928 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -21,8 +21,28 @@ use crate::nip11::{relay_info_handler, RelayInfo}; use crate::state::AppState; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. +/// +/// Uses a dual sub-router pattern so media routes can carry a 50 MB body limit +/// while all other routes remain capped at 1 MB. Each sub-router attaches its +/// own `RequestBodyLimitLayer` before merging; the outer layer adds tracing and +/// CORS once over the combined router. pub fn build_router(state: Arc) -> Router { - Router::new() + // ── Media routes: body limit derived from config ───────────────────────── + // Transport limit is max_image_bytes (50MB). GIFs have a stricter app-level cap (10MB) + // enforced in validation.rs after MIME detection. This means GIF uploads up to 50MB are + // buffered before rejection — acceptable for V1; streaming validation deferred to V2. + let media_body_limit = state.config.media.max_image_bytes as usize; + let media_router = Router::new() + .route("/media/upload", put(api::media::upload_blob)) + .route( + "/media/{sha256_ext}", + get(api::media::get_blob).head(api::media::head_blob), + ) + .layer(RequestBodyLimitLayer::new(media_body_limit)) + .with_state(state.clone()); + + // ── All other routes: 1 MB body limit ──────────────────────────────────── + let api_router = Router::new() .route("/", get(nip11_or_ws_handler)) .route("/info", get(relay_info_handler)) .route("/.well-known/nostr.json", get(api::nip05::nostr_nip05)) @@ -145,11 +165,16 @@ pub fn build_router(state: Arc) -> Router { .route("/api/users/batch", post(api::get_users_batch)) // Feed route .route("/api/feed", get(api::feed_handler)) - .layer(TraceLayer::new_for_http()) - .layer(build_cors_layer(&state.config.cors_origins)) // Reject request bodies larger than 1 MB to prevent resource exhaustion. .layer(RequestBodyLimitLayer::new(1024 * 1024)) - .with_state(state) + .with_state(state.clone()); + + // Merge — each sub-router carries its own body limit. + // TraceLayer and CORS are applied once over the combined router. + api_router + .merge(media_router) + .layer(TraceLayer::new_for_http()) + .layer(build_cors_layer(&state.config.cors_origins)) } /// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise. diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index dc333acce..985c4e842 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -14,6 +14,7 @@ use deadpool_redis; use sprout_audit::AuditService; use sprout_auth::AuthService; use sprout_db::Db; +use sprout_media::MediaStorage; use sprout_pubsub::PubSubManager; use sprout_search::SearchService; use sprout_workflow::WorkflowEngine; @@ -149,6 +150,8 @@ pub struct AppState { /// consumer skips them to avoid double delivery. Entries expire after /// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health. pub local_event_ids: Arc>, + /// Media storage client (S3/MinIO). + pub media_storage: Arc, } impl AppState { @@ -164,6 +167,7 @@ impl AppState { search: SearchService, workflow_engine: Arc, relay_keypair: nostr::Keys, + media_storage: MediaStorage, ) -> Self { let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; @@ -189,6 +193,7 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(60)) .build(), ), + media_storage: Arc::new(media_storage), } } diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml index d509ae975..3de947fcd 100644 --- a/crates/sprout-test-client/Cargo.toml +++ b/crates/sprout-test-client/Cargo.toml @@ -29,9 +29,10 @@ uuid = { workspace = true } futures-util = { workspace = true } reqwest = { workspace = true } base64 = "0.22" -sha2 = "0.10" +hex = { workspace = true } +rand = { workspace = true } +sha2 = { workspace = true } chrono = { workspace = true } -hex = "0.4" [[bin]] name = "sprout-test-cli" diff --git a/crates/sprout-test-client/tests/e2e_media.rs b/crates/sprout-test-client/tests/e2e_media.rs new file mode 100644 index 000000000..f98496fba --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_media.rs @@ -0,0 +1,383 @@ +//! End-to-end media upload tests (Blossom / NIP-96 style). +//! +//! Requires: relay running at localhost:3000, MinIO running at localhost:9000. +//! All tests are `#[ignore]` so they don't run in CI by default. +//! +//! # Running +//! +//! ```text +//! cargo test -p sprout-test-client --test e2e_media -- --ignored --nocapture +//! ``` +//! +//! Override the relay URL: +//! +//! ```text +//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p sprout-test-client --test e2e_media -- --ignored +//! ``` + +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use reqwest::Client; +use sha2::{Digest, Sha256}; + +// ── URL helpers ─────────────────────────────────────────────────────────────── + +fn relay_http_url() -> String { + std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("failed to build HTTP client") +} + +// ── Blossom auth helpers ────────────────────────────────────────────────────── + +/// Sign a kind:24242 Blossom upload auth event for the given sha256. +fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_u64(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(&["t", "upload"]).expect("t tag"), + Tag::parse(&["x", sha256]).expect("x tag"), + Tag::parse(&["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Upload test", tags) + .sign_with_keys(keys) + .expect("sign blossom auth") +} + +/// Build `Authorization: Nostr ` header value. +fn blossom_auth_header(event: &nostr::Event) -> String { + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) +} + +// ── Minimal 1×1 JPEG ───────────────────────────────────────────────────────── + +/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests. +fn tiny_jpeg() -> Vec { + vec![ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, + 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, + 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, + 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31, + 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, + 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, + 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, + 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, + 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, + 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, + 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, + 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, + 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, + 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, + 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94, + 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xD9, + ] +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// Upload a tiny JPEG, then GET it back and verify the bytes match. +/// Also checks BlobDescriptor fields and thumbnail endpoint. +#[tokio::test] +#[ignore] +async fn test_upload_and_get() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + + println!("sha256: {sha256}"); + println!("relay: {}", relay_http_url()); + + // PUT /media/upload + let auth = sign_blossom_auth(&keys, &sha256); + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("Content-Type", "image/jpeg") + .header("X-SHA-256", &sha256) + .body(jpeg.clone()) + .send() + .await + .expect("upload PUT failed"); + + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + println!("PUT /media/upload → {status}: {body_text}"); + assert_eq!(status, 200, "upload should succeed"); + + // Parse BlobDescriptor + let descriptor: serde_json::Value = + serde_json::from_str(&body_text).expect("BlobDescriptor JSON"); + println!("BlobDescriptor: {descriptor:#}"); + + assert_eq!( + descriptor["sha256"].as_str().unwrap(), + sha256, + "sha256 must match" + ); + assert!( + descriptor["url"].as_str().unwrap().contains(&sha256), + "url must contain sha256" + ); + assert!( + descriptor["size"].as_u64().unwrap() > 0, + "size must be positive" + ); + assert!( + descriptor["type"].as_str().is_some(), + "mime type must be present" + ); + // dim and blurhash are best-effort (image processing may not run on 1x1 JPEG) + println!( + "dim: {:?}, blurhash: {:?}", + descriptor["dim"], descriptor["blurhash"] + ); + + // GET /media/{sha256}.jpg — bytes must match + let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); + let get_resp = client + .get(&get_url) + .send() + .await + .expect("GET /media/{sha256}.jpg failed"); + assert_eq!(get_resp.status(), 200, "GET should return 200"); + let returned_bytes = get_resp.bytes().await.unwrap(); + assert_eq!( + returned_bytes.as_ref(), + jpeg.as_slice(), + "GET must return original bytes" + ); + + // HEAD /media/{sha256}.jpg — must return 200 with content-type + let head_resp = client + .head(&get_url) + .send() + .await + .expect("HEAD /media/{sha256}.jpg failed"); + assert_eq!(head_resp.status(), 200, "HEAD should return 200"); + assert!( + head_resp.headers().get("content-type").is_some(), + "HEAD must include content-type" + ); + + // GET thumbnail — /media/{sha256}.thumb.jpg + let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); + let thumb_resp = client + .get(&thumb_url) + .send() + .await + .expect("GET thumbnail failed"); + println!("GET thumbnail → {}", thumb_resp.status()); + // Thumbnail may be same as original for 1x1 images — just check 200 + assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200"); +} + +/// Idempotency: uploading the same file twice returns the same BlobDescriptor. +#[tokio::test] +#[ignore] +async fn test_upload_idempotent() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + + let upload = |keys: &Keys| { + let auth = sign_blossom_auth(keys, &sha256); + client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("Content-Type", "image/jpeg") + .header("X-SHA-256", sha256.clone()) + .body(jpeg.clone()) + .send() + }; + + let r1: serde_json::Value = upload(&keys) + .await + .expect("first upload failed") + .json() + .await + .expect("first descriptor parse"); + + // Second upload — different key, same content + let keys2 = Keys::generate(); + let r2: serde_json::Value = upload(&keys2) + .await + .expect("second upload failed") + .json() + .await + .expect("second descriptor parse"); + + assert_eq!( + r1["sha256"], r2["sha256"], + "sha256 must be identical on re-upload" + ); + assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload"); +} + +/// Upload without an Authorization header must return 401. +#[tokio::test] +#[ignore] +async fn test_upload_no_auth_returns_401() { + let client = http_client(); + let jpeg = tiny_jpeg(); + + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Content-Type", "image/jpeg") + .body(jpeg) + .send() + .await + .expect("request failed"); + + println!("no-auth → {}", resp.status()); + assert_eq!(resp.status(), 401, "upload without auth must be 401"); +} + +/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory). +#[tokio::test] +#[ignore] +async fn test_upload_missing_x_sha256_returns_401() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + + let auth = sign_blossom_auth(&keys, &sha256); + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("Content-Type", "image/jpeg") + // Intentionally omit X-SHA-256 + .body(jpeg) + .send() + .await + .expect("request failed"); + + println!("missing-x-sha256 → {}", resp.status()); + assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401"); +} + +/// Upload where the `x` tag sha256 doesn't match the actual body must return 401. +#[tokio::test] +#[ignore] +async fn test_upload_hash_mismatch_returns_400() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let wrong_hash = "f".repeat(64); // definitely not the real sha256 + + let auth = sign_blossom_auth(&keys, &wrong_hash); + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("Content-Type", "image/jpeg") + .header("X-SHA-256", &wrong_hash) + .body(jpeg) + .send() + .await + .expect("request failed"); + + println!("hash-mismatch → {}", resp.status()); + assert_eq!(resp.status(), 401, "hash mismatch must be 401"); +} + +/// GET a sha256 that was never uploaded must return 404. +#[tokio::test] +#[ignore] +async fn test_get_nonexistent_returns_404() { + let client = http_client(); + let missing_sha256 = "0".repeat(64); + let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); + + let resp = client.get(&url).send().await.expect("GET failed"); + println!("missing blob → {}", resp.status()); + assert_eq!(resp.status(), 404, "missing blob must be 404"); +} + +/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). +/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. +#[tokio::test] +#[ignore] +async fn test_upload_real_image() { + let image_path = match std::env::var("TEST_IMAGE_PATH") { + Ok(p) => p, + Err(_) => { + println!("Skipping: TEST_IMAGE_PATH not set"); + return; + } + }; + + let client = http_client(); + let keys = Keys::generate(); + let bytes = std::fs::read(&image_path).expect("read image file"); + let sha256 = hex::encode(Sha256::digest(&bytes)); + let size = bytes.len(); + + println!("image: {image_path}"); + println!("size: {size} bytes"); + println!("sha256: {sha256}"); + + let auth = sign_blossom_auth(&keys, &sha256); + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("Content-Type", "image/jpeg") + .header("X-SHA-256", &sha256) + .body(bytes.clone()) + .send() + .await + .expect("upload PUT failed"); + + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + println!("PUT /media/upload → {status}: {body_text}"); + assert_eq!(status, 200, "upload should succeed"); + + let descriptor: serde_json::Value = + serde_json::from_str(&body_text).expect("BlobDescriptor JSON"); + println!("BlobDescriptor: {descriptor:#}"); + + assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256); + assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64); + assert!(descriptor["url"].as_str().unwrap().contains(&sha256)); + assert!( + descriptor["dim"].as_str().is_some(), + "real image should have dim" + ); + assert!( + descriptor["blurhash"].as_str().is_some(), + "real image should have blurhash" + ); + + // GET bytes back and verify + let get_url = descriptor["url"].as_str().unwrap(); + let get_resp = client.get(get_url).send().await.expect("GET failed"); + assert_eq!(get_resp.status(), 200); + let returned = get_resp.bytes().await.unwrap(); + assert_eq!( + returned.as_ref(), + bytes.as_slice(), + "GET must return original bytes" + ); + + println!("✅ Real image upload round-trip passed"); +} diff --git a/crates/sprout-test-client/tests/e2e_media_extended.rs b/crates/sprout-test-client/tests/e2e_media_extended.rs new file mode 100644 index 000000000..a2f15ecd6 --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_media_extended.rs @@ -0,0 +1,660 @@ +//! Extended media upload integration tests — auth edge cases, content validation, +//! multi-format uploads, WebSocket imeta validation. +//! +//! Run: cargo test -p sprout-test-client --test e2e_media_extended -- --ignored --nocapture + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use reqwest::Client; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +fn relay_http_url() -> String { + std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) +} + +fn relay_ws_url() -> String { + relay_http_url() + .replace("http://", "ws://") + .replace("https://", "wss://") +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("http client") +} + +fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_u64(); + let tags = vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload test", tags) + .sign_with_keys(keys) + .unwrap() +} + +fn blossom_auth_header(event: &nostr::Event) -> String { + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) +} + +async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response { + let sha256 = hex::encode(Sha256::digest(body)); + let auth = sign_blossom_auth(keys, &sha256); + client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("X-SHA-256", &sha256) + .body(body.to_vec()) + .send() + .await + .expect("upload request") +} + +// ── Minimal test images ───────────────────────────────────────────────────── + +fn tiny_jpeg() -> Vec { + vec![ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, + 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, + 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, + 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31, + 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, + 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, + 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, + 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, + 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, + 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, + 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, + 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, + 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, + 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, + 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94, + 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xD9, + ] +} + +fn tiny_png() -> Vec { + // Valid 2x2 red PNG generated by ffmpeg + vec![ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, + 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, + 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, + ] +} + +fn tiny_gif() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD 1x1 + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // GCT + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor + 0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data + 0x3B, // Trailer + ] +} + +fn tiny_webp() -> Vec { + // Valid 2x2 red lossy WebP generated by ffmpeg + vec![ + 0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, + 0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00, + 0x02, 0x00, 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe, + 0xf0, 0xe8, 0xf7, 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07, + 0xfc, 0x80, 0xff, 0xf8, 0xf2, 0x00, 0x00, 0x00, + ] +} + +// ── Auth edge case helpers ────────────────────────────────────────────────── + +fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::from(kind), content, tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn upload_with_auth( + client: &Client, + auth_event: &nostr::Event, + sha256: &str, + body: &[u8], +) -> reqwest::Response { + client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(auth_event)) + .header("X-SHA-256", sha256) + .body(body.to_vec()) + .send() + .await + .expect("upload request") +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MULTI-FORMAT UPLOAD TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +#[ignore] +async fn test_upload_png_roundtrip() { + let client = http_client(); + let keys = Keys::generate(); + let png = tiny_png(); + let resp = upload(&client, &keys, &png).await; + assert_eq!(resp.status(), 200, "PNG upload should succeed"); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "image/png"); + assert!(desc["url"].as_str().unwrap().ends_with(".png")); + println!("✅ PNG upload: {}", desc["url"]); + + // GET back + let get = client + .get(desc["url"].as_str().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(get.status(), 200); + assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice()); + println!("✅ PNG GET roundtrip verified"); +} + +#[tokio::test] +#[ignore] +async fn test_upload_gif_roundtrip() { + let client = http_client(); + let keys = Keys::generate(); + let gif = tiny_gif(); + let resp = upload(&client, &keys, &gif).await; + assert_eq!(resp.status(), 200, "GIF upload should succeed"); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "image/gif"); + assert!(desc["url"].as_str().unwrap().ends_with(".gif")); + println!("✅ GIF upload: {}", desc["url"]); + + let get = client + .get(desc["url"].as_str().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(get.status(), 200); + assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice()); + println!("✅ GIF GET roundtrip verified"); +} + +#[tokio::test] +#[ignore] +async fn test_upload_webp_roundtrip() { + let client = http_client(); + let keys = Keys::generate(); + let webp = tiny_webp(); + let resp = upload(&client, &keys, &webp).await; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + println!("WebP upload → {status}: {body}"); + assert_eq!(status, 200, "WebP upload should succeed"); + let desc: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "image/webp"); + assert!(desc["url"].as_str().unwrap().ends_with(".webp")); + println!("✅ WebP upload: {}", desc["url"]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// AUTH EDGE CASE TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +#[ignore] +async fn test_auth_wrong_kind() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 27235, + "Upload test", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "wrong kind must be 401"); + println!("✅ Wrong kind → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_missing_t_tag() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 24242, + "Upload test", + vec![ + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "missing t tag must be 401"); + println!("✅ Missing t tag → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_missing_expiration() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let auth = sign_custom_auth( + &keys, + 24242, + "Upload test", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "missing expiration must be 401"); + println!("✅ Missing expiration → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_expired_token() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 24242, + "Upload test", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now - 60).to_string()]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "expired token must be 401"); + println!("✅ Expired token → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_empty_content() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 24242, + "", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "empty content must be 401"); + println!("✅ Empty content → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_server_tag_mismatch() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 24242, + "Upload test", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(&["server", "evil.example.com"]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 401, "server tag mismatch must be 401"); + println!("✅ Server tag mismatch → 401"); +} + +#[tokio::test] +#[ignore] +async fn test_auth_server_tag_correct() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let now = Timestamp::now().as_u64(); + let auth = sign_custom_auth( + &keys, + 24242, + "Upload test", + vec![ + Tag::parse(&["t", "upload"]).unwrap(), + Tag::parse(&["x", &sha256]).unwrap(), + Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(&["server", "localhost:3000"]).unwrap(), + ], + ); + let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; + assert_eq!(resp.status(), 200, "correct server tag must be 200"); + println!("✅ Correct server tag → 200"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONTENT VALIDATION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +#[ignore] +async fn test_reject_svg() { + let client = http_client(); + let keys = Keys::generate(); + let svg = b""; + let resp = upload(&client, &keys, svg).await; + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 415, + "SVG must be 400 or 415, got {status}" + ); + println!("✅ SVG → {status}"); +} + +#[tokio::test] +#[ignore] +async fn test_reject_pdf() { + let client = http_client(); + let keys = Keys::generate(); + let pdf = b"%PDF-1.4 fake pdf content here for testing"; + let resp = upload(&client, &keys, pdf).await; + // PDF might be 400 (unknown) or 415 (disallowed) depending on infer detection + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 415, + "PDF must be 400 or 415, got {status}" + ); + println!("✅ PDF → {status}"); +} + +#[tokio::test] +#[ignore] +async fn test_reject_zero_bytes() { + let client = http_client(); + let keys = Keys::generate(); + let resp = upload(&client, &keys, b"").await; + assert_eq!(resp.status(), 400, "zero bytes must be 400"); + println!("✅ Zero bytes → 400"); +} + +#[tokio::test] +#[ignore] +async fn test_reject_random_bytes() { + let client = http_client(); + let keys = Keys::generate(); + let random: Vec = (0..1000).map(|i| (i * 37 % 256) as u8).collect(); + let resp = upload(&client, &keys, &random).await; + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 415, + "random bytes must be rejected, got {status}" + ); + println!("✅ Random bytes → {status}"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONCURRENT UPLOAD TEST +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +#[ignore] +async fn test_concurrent_upload_same_file() { + let client = http_client(); + let jpeg = tiny_jpeg(); + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + + let c1 = client.clone(); + let c2 = client.clone(); + let j1 = jpeg.clone(); + let j2 = jpeg.clone(); + + let (r1, r2) = tokio::join!(async { upload(&c1, &keys1, &j1).await }, async { + upload(&c2, &keys2, &j2).await + },); + + assert_eq!(r1.status(), 200, "concurrent upload 1 must succeed"); + assert_eq!(r2.status(), 200, "concurrent upload 2 must succeed"); + + let d1: serde_json::Value = r1.json().await.unwrap(); + let d2: serde_json::Value = r2.json().await.unwrap(); + assert_eq!(d1["sha256"], d2["sha256"], "same content = same hash"); + assert_eq!(d1["url"], d2["url"], "same content = same URL"); + println!("✅ Concurrent upload: both succeeded, same sha256/url"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// WEBSOCKET IMETA VALIDATION +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +#[ignore] +async fn test_ws_valid_imeta() { + use sprout_test_client::SproutTestClient; + + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let http = http_client(); + + // Create channel via REST + let ch: serde_json::Value = http + .post(format!("{}/api/channels", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({ + "name": format!("ws-imeta-test-{}", rand::random::()), + "channel_type": "stream", + "visibility": "open" + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let channel_id = ch["id"].as_str().unwrap(); + println!("Channel: {channel_id}"); + + // Upload a JPEG to get a valid sha256 + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let resp = upload(&http, &keys, &jpeg).await; + assert_eq!(resp.status(), 200); + + // Connect via WebSocket + let mut client = SproutTestClient::connect(&relay_ws_url(), &keys) + .await + .unwrap(); + + // Send event with valid imeta + let event = EventBuilder::new( + Kind::from(9), + "image via ws", + vec![ + Tag::parse(&["h", channel_id]).unwrap(), + Tag::parse(&[ + "imeta", + &format!("url http://localhost:3000/media/{sha256}.jpg"), + "m image/jpeg", + &format!("x {sha256}"), + "size 347", + ]) + .unwrap(), + ], + ) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client.send_event(event).await.unwrap(); + assert!( + ok.accepted, + "valid imeta via WS must be accepted: {:?}", + ok.message + ); + println!("✅ WS valid imeta accepted"); + + client.disconnect().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn test_ws_invalid_imeta_external_url() { + use sprout_test_client::SproutTestClient; + + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let http = http_client(); + + let ch: serde_json::Value = http + .post(format!("{}/api/channels", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({ + "name": format!("ws-imeta-bad-{}", rand::random::()), + "channel_type": "stream", + "visibility": "open" + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let channel_id = ch["id"].as_str().unwrap(); + + let sha = "a".repeat(64); + let mut client = SproutTestClient::connect(&relay_ws_url(), &keys) + .await + .unwrap(); + + let event = EventBuilder::new( + Kind::from(9), + "bad imeta", + vec![ + Tag::parse(&["h", channel_id]).unwrap(), + Tag::parse(&[ + "imeta", + &format!("url https://evil.com/media/{sha}.jpg"), + "m image/jpeg", + &format!("x {sha}"), + "size 347", + ]) + .unwrap(), + ], + ) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client.send_event(event).await.unwrap(); + assert!(!ok.accepted, "external URL imeta via WS must be rejected"); + assert!( + ok.message.contains("invalid"), + "rejection message must contain 'invalid': {:?}", + ok.message + ); + println!( + "✅ WS invalid imeta (external URL) rejected: {:?}", + ok.message + ); + + client.disconnect().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn test_ws_invalid_imeta_missing_fields() { + use sprout_test_client::SproutTestClient; + + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let http = http_client(); + + let ch: serde_json::Value = http + .post(format!("{}/api/channels", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({ + "name": format!("ws-imeta-miss-{}", rand::random::()), + "channel_type": "stream", + "visibility": "open" + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let channel_id = ch["id"].as_str().unwrap(); + + let sha = "b".repeat(64); + let mut client = SproutTestClient::connect(&relay_ws_url(), &keys) + .await + .unwrap(); + + // Only url, missing m/x/size + let event = EventBuilder::new( + Kind::from(9), + "incomplete imeta", + vec![ + Tag::parse(&["h", channel_id]).unwrap(), + Tag::parse(&[ + "imeta", + &format!("url http://localhost:3000/media/{sha}.jpg"), + ]) + .unwrap(), + ], + ) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client.send_event(event).await.unwrap(); + assert!(!ok.accepted, "incomplete imeta via WS must be rejected"); + println!("✅ WS incomplete imeta rejected: {:?}", ok.message); + + client.disconnect().await.unwrap(); +} diff --git a/deny.toml b/deny.toml index e338c69b6..59118baab 100644 --- a/deny.toml +++ b/deny.toml @@ -24,6 +24,7 @@ allow = [ "CC0-1.0", "CDLA-Permissive-2.0", "MITNFA", + "MPL-2.0", ] confidence-threshold = 0.8 diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6b4b050a5..4b148d022 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -32,10 +32,11 @@ const rules = [ const overrides = new Map([ ["src/app/AppShell.tsx", 700], ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], + ["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 650], ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/tauri.ts", 900], + ["src/shared/api/tauri.ts", 950], ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c02d717df..1b178c4e3 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1969,6 +1969,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + [[package]] name = "infer" version = "0.19.0" @@ -3459,6 +3468,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4019,6 +4052,9 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "hex", + "infer 0.16.0", + "libc", "nostr 0.37.0", "reqwest 0.12.28", "serde", @@ -4027,9 +4063,13 @@ dependencies = [ "sprout-core", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-websocket", "tauri-plugin-window-state", + "tokio", + "url", + "windows-sys 0.59.0", ] [[package]] @@ -4361,6 +4401,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4483,7 +4563,7 @@ dependencies = [ "glob", "html5ever", "http", - "infer", + "infer 0.19.0", "json-patch", "kuchikiki", "log", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c6ee939a7..0fa1062da 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -17,15 +17,26 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } + [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-window-state = "2" tauri-plugin-websocket = "2" +tauri-plugin-dialog = "2" +infer = "0.16" +hex = "0.4" +tokio = { version = "1", features = ["fs", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" nostr = "0.37" reqwest = { version = "0.12", features = ["json"] } +url = "2" sprout-core = { path = "../../crates/sprout-core" } base64 = "0.22" sha2 = "0.10" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 9d1fb1f61..e3b547f27 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-close", "opener:default", "websocket:default", - "window-state:default" + "window-state:default", + "dialog:default" ] } diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs new file mode 100644 index 000000000..99480ff8a --- /dev/null +++ b/desktop/src-tauri/src/commands/media.rs @@ -0,0 +1,336 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; + +use crate::app_state::AppState; +use crate::relay::relay_api_base_url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobDescriptor { + pub url: String, + pub sha256: String, + pub size: u64, + #[serde(rename = "type")] + pub mime_type: String, + pub uploaded: i64, + pub dim: Option, + pub blurhash: Option, + pub thumb: Option, +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Extract the server authority from a URL for BUD-11 server tag scoping. +/// +/// Returns `host` for default ports (80/443), `host:port` for non-default ports. +fn extract_server_authority(url_str: &str) -> Option { + let parsed = url::Url::parse(url_str).ok()?; + let host = parsed.host_str()?; + match parsed.port() { + Some(port) => Some(format!("{host}:{port}")), + None => Some(host.to_string()), + } +} + +/// Resolve the real filesystem path of an already-opened file descriptor. +/// +/// Returns the path the kernel associates with the inode, not the pathname +/// used to open it. Immune to post-open renames/symlink swaps. +#[cfg(target_os = "macos")] +fn fd_real_path(file: &std::fs::File) -> Result { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + let mut buf = vec![0u8; libc::PATH_MAX as usize]; + let ret = unsafe { libc::fcntl(fd, libc::F_GETPATH, buf.as_mut_ptr()) }; + if ret == -1 { + return Err(format!( + "fcntl F_GETPATH failed: {}", + std::io::Error::last_os_error() + )); + } + let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let s = std::str::from_utf8(&buf[..nul]).map_err(|e| e.to_string())?; + Ok(std::path::PathBuf::from(s)) +} + +#[cfg(target_os = "linux")] +fn fd_real_path(file: &std::fs::File) -> Result { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + std::fs::read_link(format!("/proc/self/fd/{fd}")).map_err(|e| e.to_string()) +} + +#[cfg(target_os = "windows")] +fn fd_real_path(file: &std::fs::File) -> Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED, + }; + let handle = file.as_raw_handle() as isize; + let mut buf = vec![0u16; 1024]; + let len = unsafe { + GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, FILE_NAME_NORMALIZED) + }; + if len == 0 { + return Err(format!( + "GetFinalPathNameByHandleW failed: {}", + std::io::Error::last_os_error() + )); + } + let path_str = String::from_utf16_lossy(&buf[..len as usize]); + // Strip \\?\ prefix that Windows adds + let cleaned = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str); + Ok(std::path::PathBuf::from(cleaned)) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn fd_real_path(_file: &std::fs::File) -> Result { + Err("fd_real_path not supported on this platform".to_string()) +} + +/// MIME allowlist — must match the server's `ALLOWED_MIME_TYPES`. +const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; + +fn detect_and_validate_mime(body: &[u8]) -> Result { + let mime = infer::get(body) + .map(|t| t.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + if !ALLOWED_MIME.contains(&mime.as_str()) { + return Err(format!("unsupported file type: {mime}")); + } + Ok(mime) +} + +fn sign_blossom_upload_auth(keys: &Keys, sha256: &str) -> Result { + let now = Timestamp::now().as_u64(); + let mut tags = vec![ + Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?, + Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?, + Tag::parse(vec!["expiration", &(now + 300).to_string()]).map_err(|e| e.to_string())?, + ]; + let base_url = relay_api_base_url(); + if let Some(domain) = extract_server_authority(&base_url) { + tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); + } + EventBuilder::new(Kind::from(24242), "Upload sprout-media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) +} + +/// Execute the upload HTTP request. Shared by all upload entry points. +async fn do_upload( + body: Vec, + mime: &str, + state: &State<'_, AppState>, +) -> Result { + let sha256 = format!("{:x}", Sha256::digest(&body)); + + let auth_event = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + sign_blossom_upload_auth(&keys, &sha256)? + }; + + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + let base_url = relay_api_base_url(); + let mut req = state + .http_client + .put(format!("{base_url}/media/upload")) + .header("Authorization", &auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", &sha256); + + if let Some(ref token) = state.configured_api_token { + req = req.header("X-Auth-Token", token.as_str()); + } else if let Ok(guard) = state.session_token.lock() { + if let Some(ref token) = *guard { + req = req.header("X-Auth-Token", token.as_str()); + } + } + + let resp = req + .body(body) + .send() + .await + .map_err(|e| format!("upload failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("upload failed ({status}): {text}")); + } + + resp.json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +/// Upload a file that is already in the OS temp directory. +/// +/// Trust boundary: only reads files inside `temp_dir()`. Opens the fd first, +/// then resolves the fd's real path to verify containment (TOCTOU-safe). +#[tauri::command] +pub async fn upload_media( + file_path: String, + is_temp: bool, + state: State<'_, AppState>, +) -> Result { + let path = std::path::Path::new(&file_path); + let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?; + + let fd_path = fd_real_path(&file)?; + let canonical_temp = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + if !fd_path.starts_with(&canonical_temp) { + return Err("upload source must be in system temp directory".to_string()); + } + + use std::io::Read; + let mut body = Vec::new(); + file.read_to_end(&mut body) + .map_err(|e| format!("failed to read file: {e}"))?; + drop(file); + + if is_temp { + let _ = std::fs::remove_file(&fd_path); + } + + let mime = detect_and_validate_mime(&body)?; + do_upload(body, &mime, &state).await +} + +/// Open a native file dialog, read the selected file, and upload it. +/// +/// All file I/O happens in trusted Rust — the renderer never touches the +/// filesystem. This is the secure path for the 📎 paperclip button. +/// +/// **Residual TOCTOU note:** The Tauri dialog plugin returns a pathname, not +/// a file handle, so there is a small race window between dialog return and +/// `File::open()`. This is an inherent limitation of the OS file-picker API +/// (no platform exposes a handle/bookmark from the open-file dialog in a way +/// the Tauri plugin surfaces). The risk is bounded: the attacker must be local +/// and must win a race against an immediate open. Server-side content validation +/// (MIME, image decode, size caps) provides defense-in-depth. +#[tauri::command] +pub async fn pick_and_upload_media( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("Images", &["jpg", "jpeg", "png", "gif", "webp"]) + .pick_file(move |path| { + let _ = tx.send(path); + }); + + let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; + let file_path = match selected { + Some(p) => p, + None => return Ok(None), + }; + + // Use the same fd-first pattern as upload_media: open the file to pin + // the inode, then read from the handle. Prevents a local attacker from + // swapping the selected path between dialog return and read. + let path = file_path.as_path().ok_or("invalid path")?; + let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?; + + use std::io::Read; + let mut body = Vec::new(); + file.read_to_end(&mut body) + .map_err(|e| format!("failed to read file: {e}"))?; + drop(file); + + let mime = detect_and_validate_mime(&body)?; + do_upload(body, &mime, &state).await.map(Some) +} + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// Passing them via IPC avoids granting the renderer filesystem write access. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + state: State<'_, AppState>, +) -> Result { + if data.is_empty() { + return Err("empty upload".to_string()); + } + let mime = detect_and_validate_mime(&data)?; + do_upload(data, &mime, &state).await +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_server_authority_default_ports() { + assert_eq!( + extract_server_authority("https://relay.example.com"), + Some("relay.example.com".to_string()) + ); + assert_eq!( + extract_server_authority("https://relay.example.com:443"), + Some("relay.example.com".to_string()) + ); + assert_eq!( + extract_server_authority("http://relay.example.com:80"), + Some("relay.example.com".to_string()) + ); + } + + #[test] + fn test_extract_server_authority_non_default_ports() { + assert_eq!( + extract_server_authority("http://localhost:3000"), + Some("localhost:3000".to_string()) + ); + assert_eq!( + extract_server_authority("https://relay.example.com:8443"), + Some("relay.example.com:8443".to_string()) + ); + } + + #[test] + fn test_extract_server_authority_ipv6() { + assert_eq!( + extract_server_authority("http://[::1]:3000"), + Some("[::1]:3000".to_string()) + ); + } + + #[test] + fn test_extract_server_authority_invalid() { + assert_eq!(extract_server_authority("not-a-url"), None); + assert_eq!(extract_server_authority(""), None); + } + + #[test] + fn test_detect_and_validate_mime_jpeg() { + // Minimal JPEG: SOI + EOI + let jpeg = [0xFF, 0xD8, 0xFF, 0xE0]; + assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg"); + } + + #[test] + fn test_detect_and_validate_mime_rejects_text() { + let text = b"hello world"; + assert!(detect_and_validate_mime(text).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b70059554..d76c55d4e 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -45,6 +45,8 @@ pub async fn send_channel_message( channel_id: String, content: String, parent_event_id: Option, + media_tags: Option>>, + mention_pubkeys: Option>, state: State<'_, AppState>, ) -> Result { let path = format!("/api/channels/{channel_id}/messages"); @@ -53,6 +55,8 @@ pub async fn send_channel_message( content: content.trim(), parent_event_id: parent_event_id.as_deref(), broadcast_to_channel: false, + media_tags, + mention_pubkeys: mention_pubkeys.unwrap_or_default(), }, ); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 242983999..b80cf7eee 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ mod agents; mod channels; mod identity; +mod media; mod messages; mod profile; mod tokens; @@ -8,6 +9,7 @@ mod tokens; pub use agents::*; pub use channels::*; pub use identity::*; +pub use media::*; pub use messages::*; pub use profile::*; pub use tokens::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 48a5cc652..5f2af6ae0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_websocket::init()) + .plugin(tauri_plugin_dialog::init()) .manage(build_app_state()) .invoke_handler(tauri::generate_handler![ get_identity, @@ -53,6 +54,9 @@ pub fn run() { add_reaction, remove_reaction, get_event, + upload_media, + pick_and_upload_media, + upload_media_bytes, list_tokens, mint_token, revoke_token, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 020885b5d..77104538b 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -174,6 +174,10 @@ pub struct SendChannelMessageBody<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub parent_event_id: Option<&'a str>, pub broadcast_to_channel: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub media_tags: Option>>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub mention_pubkeys: Vec, } #[derive(Serialize)] diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index aa8bc3f88..e6e8705c1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -620,11 +620,12 @@ export function AppShell() { onCancelReply={() => { setReplyTargetId(null); }} - onSend={async (content, mentionPubkeys) => { + onSend={async (content, mentionPubkeys, mediaTags) => { await sendMessageMutation.mutateAsync({ content, mentionPubkeys, parentEventId: replyTargetId, + mediaTags, }); setReplyTargetId(null); }} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index f1040d164..a05cc5bd9 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -62,6 +62,7 @@ function createOptimisticMessage( currentMessages: RelayEvent[], mentionPubkeys: string[] = [], parentEventId: string | null = null, + mediaTags: string[][] = [], ): RelayEvent { const tags: string[][] = []; @@ -81,6 +82,10 @@ function createOptimisticMessage( } } + for (const tag of mediaTags) { + tags.push(tag); + } + return { id: `optimistic-${crypto.randomUUID()}`, pubkey: identity.pubkey, @@ -218,10 +223,16 @@ export function useSendMessageMutation( content: string; mentionPubkeys?: string[]; parentEventId?: string | null; + mediaTags?: string[][]; }, MessageQueryContext | undefined >({ - mutationFn: async ({ content, mentionPubkeys, parentEventId }) => { + mutationFn: async ({ + content, + mentionPubkeys, + parentEventId, + mediaTags, + }) => { if (!channel || channel.channelType === "forum") { throw new Error("This channel does not support message sending yet."); } @@ -230,7 +241,9 @@ export function useSendMessageMutation( throw new Error("No identity available for sending messages."); } - if (parentEventId) { + // Media-bearing messages MUST go through REST so the relay's imeta + // validation runs. The WebSocket path does not validate imeta tags. + if (parentEventId || (mediaTags && mediaTags.length > 0)) { const cachedMessages = queryClient.getQueryData([ "channel-messages", @@ -239,28 +252,52 @@ export function useSendMessageMutation( const result = await sendChannelMessage( channel.id, content, - parentEventId, + parentEventId ?? null, + mediaTags, + mentionPubkeys, ); + // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta. + // For replies, buildReplyTags already includes ["p", author] and ["h", channel]. + // For non-replies (media-only), we add them ourselves. + const replyTags = parentEventId + ? buildReplyTags( + channel.id, + identity.pubkey, + parentEventId, + resolveReplyRootId(parentEventId, cachedMessages), + ) + : []; + const baseTags = parentEventId + ? replyTags // buildReplyTags includes h + author p + : [ + ["h", channel.id], + ["p", identity.pubkey], + ]; // non-reply: add ourselves + return { id: result.eventId, pubkey: identity.pubkey, created_at: result.createdAt, kind: KIND_STREAM_MESSAGE, - tags: buildReplyTags( - channel.id, - identity.pubkey, - parentEventId, - resolveReplyRootId(parentEventId, cachedMessages), - ), + tags: [ + ...baseTags, + ...(mentionPubkeys ?? []).map((pk) => ["p", pk]), + ...(mediaTags ?? []), + ], content: content.trim(), sig: "", }; } - return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []); + return relayClient.sendMessage( + channel.id, + content, + mentionPubkeys ?? [], + [], + ); }, - onMutate: async ({ content, mentionPubkeys, parentEventId }) => { + onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => { if (!channel || !identity || channel.channelType === "forum") { return undefined; } @@ -277,6 +314,7 @@ export function useSendMessageMutation( previousMessages, mentionPubkeys ?? [], parentEventId ?? null, + mediaTags ?? [], ); queryClient.setQueryData( diff --git a/desktop/src/features/messages/lib/parseImeta.ts b/desktop/src/features/messages/lib/parseImeta.ts new file mode 100644 index 000000000..37c367b2f --- /dev/null +++ b/desktop/src/features/messages/lib/parseImeta.ts @@ -0,0 +1,52 @@ +export type ImetaEntry = { + url: string; + m: string; + x: string; + size: number; + dim?: string; + blurhash?: string; + alt?: string; + thumb?: string; +}; + +export function parseImetaTags(tags: string[][]): Map { + const map = new Map(); + for (const tag of tags) { + if (tag[0] !== "imeta") continue; + const entry: Partial = {}; + for (const part of tag.slice(1)) { + const spaceIdx = part.indexOf(" "); + if (spaceIdx === -1) continue; + const key = part.slice(0, spaceIdx); + const val = part.slice(spaceIdx + 1); + switch (key) { + case "url": + entry.url = val; + break; + case "m": + entry.m = val; + break; + case "x": + entry.x = val; + break; + case "size": + entry.size = parseInt(val, 10); + break; + case "dim": + entry.dim = val; + break; + case "blurhash": + entry.blurhash = val; + break; + case "alt": + entry.alt = val; + break; + case "thumb": + entry.thumb = val; + break; + } + } + if (entry.url) map.set(entry.url, entry as ImetaEntry); + } + return map; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 974b8a744..d22d68293 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -3,6 +3,11 @@ import * as React from "react"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + type BlobDescriptor, + pickAndUploadMedia, + uploadMediaBytes, +} from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Textarea } from "@/shared/ui/textarea"; import { @@ -16,7 +21,11 @@ type MessageComposerProps = { disabled?: boolean; isSending?: boolean; onCancelReply?: () => void; - onSend: (content: string, mentionPubkeys: string[]) => Promise; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; placeholder?: string; replyTarget?: { author: string; @@ -67,6 +76,13 @@ export function MessageComposer({ const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0); const mentionMapRef = React.useRef>(new Map()); + // Upload state + const [uploadState, setUploadState] = React.useState<{ + status: "idle" | "uploading" | "error"; + message?: string; + }>({ status: "idle" }); + const [pendingImeta, setPendingImeta] = React.useState([]); + const membersQuery = useChannelMembersQuery(channelId); const members = membersQuery.data ?? []; const managedAgentsQuery = useManagedAgentsQuery(); @@ -179,23 +195,149 @@ export function MessageComposer({ [members, managedAgentNamesByPubkey], ); + // Shared handler: got a descriptor back from any upload path. + const onUploaded = React.useCallback((descriptor: BlobDescriptor) => { + const markdown = `\n![image](${descriptor.url})\n`; + setContent((prev) => prev + markdown); + setPendingImeta((prev) => [...prev, descriptor]); + setUploadState({ status: "idle" }); + }, []); + + // 📎 Paperclip: native file dialog + read + upload, all in trusted Rust. + // The renderer never touches the filesystem. + const handlePaperclip = React.useCallback(async () => { + setUploadState({ status: "uploading" }); + try { + const descriptor = await pickAndUploadMedia(); + if (descriptor) { + onUploaded(descriptor); + } else { + setUploadState({ status: "idle" }); // user cancelled dialog + } + } catch (err) { + setUploadState({ status: "error", message: String(err) }); + } + }, [onUploaded]); + + // 🖱️ Drop: read bytes in JS, send via IPC to Rust for upload. + // No filesystem access needed — bytes come from the drag event. + const handleDrop = React.useCallback( + async (event: React.DragEvent) => { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + if (files.length === 0) return; + + const file = files[0]; + if (!file) return; + + const ALLOWED_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]; + if (!ALLOWED_TYPES.includes(file.type)) { + setUploadState({ + status: "error", + message: "Only JPEG, PNG, GIF, and WebP images are supported", + }); + return; + } + + setUploadState({ status: "uploading" }); + try { + const buffer = await file.arrayBuffer(); + const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]); + onUploaded(descriptor); + } catch (err) { + setUploadState({ status: "error", message: String(err) }); + } + }, + [onUploaded], + ); + + const handleDragOver = React.useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + }, + [], + ); + + // 📋 Paste: read bytes from clipboard, send via IPC to Rust for upload. + const handlePaste = React.useCallback( + async (event: React.ClipboardEvent) => { + const items = Array.from(event.clipboardData.items); + const ALLOWED_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]; + const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type)); + if (!imageItem) return; + + event.preventDefault(); + const file = imageItem.getAsFile(); + if (!file) return; + + setUploadState({ status: "uploading" }); + try { + const buffer = await file.arrayBuffer(); + const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]); + onUploaded(descriptor); + } catch (err) { + setUploadState({ status: "error", message: String(err) }); + } + }, + [onUploaded], + ); + const submitMessage = React.useCallback(async () => { const trimmed = content.trim(); - if (!trimmed || disabled || isSending) { + const hasMedia = pendingImeta.length > 0; + if ((!trimmed && !hasMedia) || disabled || isSending) { return; } const pubkeys = extractMentionPubkeys(trimmed); + + // Build imeta tags from pending descriptors + const mediaTags = + pendingImeta.length > 0 + ? pendingImeta.map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + `size ${d.size}`, + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ]) + : undefined; + + const savedContent = trimmed; + const savedImeta = [...pendingImeta]; + setContent(""); + setPendingImeta([]); mentionMapRef.current.clear(); setMentionQuery(null); try { - await onSend(trimmed, pubkeys); + await onSend(trimmed, pubkeys, mediaTags); } catch { - setContent(trimmed); + setContent(savedContent); + setPendingImeta(savedImeta); } - }, [content, disabled, isSending, onSend, extractMentionPubkeys]); + }, [ + content, + disabled, + isSending, + onSend, + extractMentionPubkeys, + pendingImeta, + ]); const handleSubmit = React.useCallback( (event: React.FormEvent) => { @@ -328,12 +470,18 @@ export function MessageComposer({ textareaRef.current?.focus(); }, [disabled, replyTarget]); + const isUploading = uploadState.status === "uploading"; + return (