From f9828d4887c4fb71651e108bd006dad8b1c86f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:14:58 +0000 Subject: [PATCH] Serveur Rust : socle (config, base, sessions, chiffrement, migrations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squelette du nouveau serveur axum/sqlx : accès aux données porté de storage.ts, sessions compatibles connect-pg-simple, chiffrement des jetons compatible, migrations idempotentes, en-têtes de sécurité, limitation de débit et service des fichiers statiques. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- .gitignore | 4 +- server/Cargo.lock | 4312 ++++++++++++++++++++++++++ server/Cargo.toml | 49 + server/src/auth.rs | 354 +++ server/src/config.rs | 126 + server/src/crypto.rs | 152 + server/src/db/mod.rs | 1238 ++++++++ server/src/db/models.rs | 371 +++ server/src/error.rs | 99 + server/src/extract.rs | 132 + server/src/http.rs | 163 + server/src/main.rs | 102 + server/src/migrate.rs | 301 ++ server/src/public_url.rs | 45 + server/src/routes/analytics.rs | 9 + server/src/routes/auth.rs | 86 + server/src/routes/core/mod.rs | 9 + server/src/routes/external.rs | 9 + server/src/routes/facebook.rs | 9 + server/src/routes/legal.rs | 9 + server/src/routes/mod.rs | 96 + server/src/routes/reels.rs | 9 + server/src/routes/tiktok.rs | 9 + server/src/services/analytics.rs | 11 + server/src/services/audio.rs | 84 + server/src/services/cron.rs | 8 + server/src/services/facebook/mod.rs | 40 + server/src/services/ffmpeg.rs | 44 + server/src/services/media_purge.rs | 10 + server/src/services/media_store.rs | 180 ++ server/src/services/mod.rs | 15 + server/src/services/openrouter.rs | 24 + server/src/services/reels/mod.rs | 24 + server/src/services/scheduler.rs | 8 + server/src/services/thumbnail.rs | 93 + server/src/services/tiktok/mod.rs | 24 + server/src/services/token_manager.rs | 26 + server/src/state.rs | 72 + server/src/time.rs | 156 + server/src/upload.rs | 136 + 40 files changed, 8647 insertions(+), 1 deletion(-) create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/src/auth.rs create mode 100644 server/src/config.rs create mode 100644 server/src/crypto.rs create mode 100644 server/src/db/mod.rs create mode 100644 server/src/db/models.rs create mode 100644 server/src/error.rs create mode 100644 server/src/extract.rs create mode 100644 server/src/http.rs create mode 100644 server/src/main.rs create mode 100644 server/src/migrate.rs create mode 100644 server/src/public_url.rs create mode 100644 server/src/routes/analytics.rs create mode 100644 server/src/routes/auth.rs create mode 100644 server/src/routes/core/mod.rs create mode 100644 server/src/routes/external.rs create mode 100644 server/src/routes/facebook.rs create mode 100644 server/src/routes/legal.rs create mode 100644 server/src/routes/mod.rs create mode 100644 server/src/routes/reels.rs create mode 100644 server/src/routes/tiktok.rs create mode 100644 server/src/services/analytics.rs create mode 100644 server/src/services/audio.rs create mode 100644 server/src/services/cron.rs create mode 100644 server/src/services/facebook/mod.rs create mode 100644 server/src/services/ffmpeg.rs create mode 100644 server/src/services/media_purge.rs create mode 100644 server/src/services/media_store.rs create mode 100644 server/src/services/mod.rs create mode 100644 server/src/services/openrouter.rs create mode 100644 server/src/services/reels/mod.rs create mode 100644 server/src/services/scheduler.rs create mode 100644 server/src/services/thumbnail.rs create mode 100644 server/src/services/tiktok/mod.rs create mode 100644 server/src/services/token_manager.rs create mode 100644 server/src/state.rs create mode 100644 server/src/time.rs create mode 100644 server/src/upload.rs diff --git a/.gitignore b/.gitignore index 7c0bbcb..4fa766c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ npm-debug.log* *~ # OS -Thumbs.db \ No newline at end of file +Thumbs.db +# Rust +server/target diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..b4c7eff --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,4312 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" +dependencies = [ + "base64 0.23.1", + "blowfish", + "getrandom 0.4.3", + "subtle", + "zeroize", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "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", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "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.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +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 0.8.9", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socialflow" +version = "2.0.0" +dependencies = [ + "aes-gcm", + "anyhow", + "axum", + "axum-extra", + "base64 0.22.1", + "bcrypt", + "bytes", + "chrono", + "futures", + "hex", + "hmac", + "image", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "regex", + "reqwest", + "resvg", + "scrypt", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "flate2", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..597248a --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "socialflow" +version = "2.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "socialflow" +path = "src/main.rs" + +[dependencies] +aes-gcm = "0.10" +anyhow = "1.0.104" +axum = { version = "0.8.9", features = ["multipart", "macros"] } +axum-extra = { version = "0.12.6", features = ["cookie", "typed-header"] } +base64 = "0.22" +bcrypt = "0.19.3" +bytes = "1.12.1" +chrono = { version = "0.4.45", features = ["serde"] } +futures = "0.3.34" +hex = "0.4.3" +hmac = "0.12" +image = "0.25.10" +once_cell = "1.21.4" +percent-encoding = "2.3.2" +rand = "0.8" +regex = "1.13.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls-native-roots", "gzip", "http2"] } +resvg = "0.48.1" +scrypt = "0.11" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = { version = "1.0.154", features = ["preserve_order"] } +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "json", "uuid", "macros", "migrate"] } +thiserror = "2.0.21" +tokio = { version = "1.53.2", features = ["full"] } +tokio-util = { version = "0.7.20", features = ["io"] } +tower = "0.5.3" +tower-http = { version = "0.7.1", features = ["fs", "trace", "set-header", "cors", "limit"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +url = "2.5.8" +uuid = { version = "1.28.0", features = ["v4", "serde"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 4 +strip = true diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..fa95301 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,354 @@ +//! Authentification par session. +//! +//! Compatible avec l'ancien couple express-session + connect-pg-simple : +//! même cookie (`connect.sid`, signé HMAC-SHA256 avec `SESSION_SECRET`), +//! même table `session`, même structure JSON (`passport.user`). Les sessions +//! ouvertes avant la mise à jour restent donc valides. + +use std::ops::Deref; + +use axum::extract::FromRequestParts; +use axum::http::header::{COOKIE, SET_COOKIE}; +use axum::http::request::Parts; +use axum::http::{HeaderMap, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use chrono::Duration; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde_json::{json, Map, Value}; +use sha2::Sha256; + +use crate::db::{User, UserRole}; +use crate::error::AppError; +use crate::public_url::is_https; +use crate::state::AppState; +use crate::time::Ts; + +pub const COOKIE_NAME: &str = "connect.sid"; +/// Durée de vie d'une session : 7 jours. +pub const SESSION_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000; + +const COOKIE_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'!') + .remove(b'~') + .remove(b'*') + .remove(b'\'') + .remove(b'(') + .remove(b')'); + +/// Session chargée depuis la base ; `data` est l'objet JSON stocké (`sess`). +#[derive(Debug, Clone)] +pub struct Session { + pub sid: String, + pub data: Value, +} + +impl Session { + pub fn user_id(&self) -> Option<&str> { + self.data.get("passport")?.get("user")?.as_str() + } + + pub fn get(&self, key: &str) -> Option<&Value> { + self.data.get(key) + } + + pub fn get_str(&self, key: &str) -> Option { + self.data.get(key).and_then(Value::as_str).map(str::to_string) + } + + pub fn set(&mut self, key: &str, value: Value) { + if let Value::Object(map) = &mut self.data { + map.insert(key.to_string(), value); + } + } + + pub fn remove(&mut self, key: &str) -> Option { + match &mut self.data { + Value::Object(map) => map.remove(key), + _ => None, + } + } + + /// Enregistre les données modifiées (et prolonge la session). + pub async fn save(&self, state: &AppState) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE session SET sess = $2, expire = $3 WHERE sid = $1") + .bind(&self.sid) + .bind(&self.data) + .bind(Ts::now().add(Duration::milliseconds(SESSION_TTL_MS))) + .execute(&state.db.pool) + .await?; + Ok(()) + } +} + +fn sign(secret: &str, value: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé"); + mac.update(value.as_bytes()); + let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()); + format!("{value}.{}", sig.trim_end_matches('=')) +} + +/// `s:.` → sid, si la signature est valide. +fn unsign(secret: &str, signed: &str) -> Option { + let raw = signed.strip_prefix("s:")?; + let (value, _) = raw.rsplit_once('.')?; + let expected = sign(secret, value); + // Comparaison à temps constant + let a = expected.as_bytes(); + let b = raw.as_bytes(); + if a.len() != b.len() { + return None; + } + let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)); + (diff == 0).then(|| value.to_string()) +} + +fn session_cookie_value(headers: &HeaderMap) -> Option { + for header in headers.get_all(COOKIE) { + let Ok(text) = header.to_str() else { continue }; + for pair in text.split(';') { + let pair = pair.trim(); + if let Some(value) = pair.strip_prefix(&format!("{COOKIE_NAME}=")) { + return percent_encoding::percent_decode_str(value) + .decode_utf8() + .ok() + .map(|v| v.into_owned()); + } + } + } + None +} + +/// Identifiant de session (comme `uid-safe(24)` : 32 caractères base64url). +fn new_sid() -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn cookie_meta(secure: bool, expires: Ts) -> Value { + json!({ + "originalMaxAge": SESSION_TTL_MS, + "expires": expires.to_iso(), + "secure": secure, + "httpOnly": true, + "path": "/", + "sameSite": "lax", + }) +} + +/// En-tête `Set-Cookie` de la session. +pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: Ts) -> HeaderValue { + let signed = sign(&state.config.session_secret, sid); + let encoded = percent_encoding::utf8_percent_encode(&format!("s:{signed}"), COOKIE_ENCODE).to_string(); + let mut cookie = format!( + "{COOKIE_NAME}={encoded}; Path=/; Expires={}; HttpOnly; SameSite=Lax", + expires.utc().format("%a, %d %b %Y %H:%M:%S GMT") + ); + if is_https(state, headers) { + cookie.push_str("; Secure"); + } + HeaderValue::from_str(&cookie).expect("cookie ASCII") +} + +/// En-tête `Set-Cookie` qui efface la session côté navigateur. +pub fn clear_session_cookie() -> HeaderValue { + HeaderValue::from_static("connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax") +} + +/// Charge la session de la requête (cookie valide et session non expirée). +pub async fn load_session(state: &AppState, headers: &HeaderMap) -> Option { + let cookie = session_cookie_value(headers)?; + let sid = unsign(&state.config.session_secret, &cookie)?; + let row: Option<(Value,)> = sqlx::query_as("SELECT sess FROM session WHERE sid = $1 AND expire > $2") + .bind(&sid) + .bind(Ts::now()) + .fetch_optional(&state.db.pool) + .await + .ok()?; + row.map(|(data,)| Session { sid, data }) +} + +/// Ouvre une nouvelle session pour l'utilisateur (l'ancienne est détruite, +/// comme le faisait passport lors de la connexion). +pub async fn start_session( + state: &AppState, + headers: &HeaderMap, + previous: Option<&Session>, + user_id: &str, +) -> Result<(Session, HeaderValue), sqlx::Error> { + if let Some(previous) = previous { + destroy_session(state, &previous.sid).await?; + } + let sid = new_sid(); + let expires = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS)); + let data = json!({ + "cookie": cookie_meta(is_https(state, headers), expires), + "passport": { "user": user_id }, + }); + sqlx::query("INSERT INTO session (sid, sess, expire) VALUES ($1, $2, $3)") + .bind(&sid) + .bind(&data) + .bind(expires) + .execute(&state.db.pool) + .await?; + let cookie = session_cookie(state, headers, &sid, expires); + Ok((Session { sid, data }, cookie)) +} + +pub async fn destroy_session(state: &AppState, sid: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM session WHERE sid = $1").bind(sid).execute(&state.db.pool).await?; + Ok(()) +} + +/// Prolonge la session en base (équivalent du `touch` de connect-pg-simple). +fn touch_session(state: &AppState, sid: String) { + let state = state.clone(); + tokio::spawn(async move { + let expire = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS)); + let _ = sqlx::query("UPDATE session SET expire = $2 WHERE sid = $1") + .bind(sid) + .bind(expire) + .execute(&state.db.pool) + .await; + }); +} + +/// Purge des sessions expirées (appelée périodiquement). +pub async fn prune_sessions(state: &AppState) { + let _ = sqlx::query("DELETE FROM session WHERE expire < $1") + .bind(Ts::now()) + .execute(&state.db.pool) + .await; +} + +/// Utilisateur connecté et sa session. +pub async fn current_user(state: &AppState, headers: &HeaderMap) -> Option<(User, Session)> { + let session = load_session(state, headers).await?; + let user_id = session.user_id()?.to_string(); + let user = state.db.get_user(&user_id).await.ok()??; + touch_session(state, session.sid.clone()); + Some((user, session)) +} + +/// Extracteur : utilisateur authentifié, sinon `401 { error: "Non authentifié" }`. +pub struct AuthUser { + pub user: User, + pub session: Session, +} + +impl Deref for AuthUser { + type Target = User; + + fn deref(&self) -> &User { + &self.user + } +} + +impl FromRequestParts for AuthUser { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + match current_user(state, &parts.headers).await { + Some((user, session)) => Ok(AuthUser { user, session }), + None => Err(AppError::unauthorized("Non authentifié")), + } + } +} + +/// Extracteur : administrateur, sinon 401 / 403. +pub struct AdminUser { + pub user: User, + pub session: Session, +} + +impl Deref for AdminUser { + type Target = User; + + fn deref(&self) -> &User { + &self.user + } +} + +impl FromRequestParts for AdminUser { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let AuthUser { user, session } = AuthUser::from_request_parts(parts, state).await?; + if user.role != UserRole::Admin { + return Err(AppError::forbidden("Accès refusé. Réservé aux administrateurs.")); + } + Ok(AdminUser { user, session }) + } +} + +/// Extracteur optionnel. +pub struct MaybeUser(pub Option<(User, Session)>); + +impl FromRequestParts for MaybeUser { + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + Ok(MaybeUser(current_user(state, &parts.headers).await)) + } +} + +/// Réponse JSON accompagnée d'un `Set-Cookie`. +pub fn with_cookie(body: Value, cookie: HeaderValue) -> Response { + let mut response = axum::Json(body).into_response(); + response.headers_mut().append(SET_COOKIE, cookie); + response +} + +pub fn user_json(user: &User) -> Value { + let mut map = Map::new(); + map.insert("id".into(), Value::String(user.id.clone())); + map.insert("username".into(), Value::String(user.username.clone())); + map.insert("role".into(), Value::String(user.role.as_str().into())); + Value::Object(map) +} + +pub async fn hash_password(password: &str) -> anyhow::Result { + let password = password.to_string(); + Ok(tokio::task::spawn_blocking(move || bcrypt::hash(password, 10)).await??) +} + +pub async fn verify_password(password: &str, hash: &str) -> bool { + let password = password.to_string(); + let hash = hash.to_string(); + tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false)) + .await + .unwrap_or(false) +} + +/// Crée l'utilisateur `admin` / `admin` au premier démarrage. +pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> { + if state.db.get_user_by_username("admin").await?.is_some() { + tracing::info!("✓ Utilisateur admin existe déjà"); + return Ok(()); + } + tracing::info!("🔧 Création de l'utilisateur admin par défaut..."); + let hash = hash_password("admin").await?; + state.db.create_user("admin", &hash, UserRole::Admin).await?; + tracing::info!("✅ Utilisateur admin créé avec succès (admin / admin)"); + tracing::warn!("⚠️ IMPORTANT: Changez ce mot de passe immédiatement !"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signature_matches_cookie_signature_package() { + // Valeur produite par `cookie-signature` (Node) : sign('abc', 'secret') + assert_eq!(sign("secret", "abc"), "abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4"); + assert_eq!(unsign("secret", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").as_deref(), Some("abc")); + assert!(unsign("secret", "s:abc.invalid").is_none()); + assert!(unsign("other", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").is_none()); + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..3b0c307 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,126 @@ +//! Configuration lue dans l'environnement (mêmes variables que l'ancien serveur). + +use std::path::PathBuf; + +use anyhow::bail; +use rand::RngCore; + +#[derive(Debug, Clone)] +pub struct Config { + pub port: u16, + pub database_url: String, + pub session_secret: String, + pub production: bool, + /// URL publique (ex. https://socialflow.exemple.fr), sans « / » final. + pub app_url: Option, + /// URL interne Docker par laquelle les autres services joignent l'app. + pub internal_app_url: Option, + /// Frontend compilé (Vite) : `dist/public`. + pub static_dir: PathBuf, + /// Fichiers servis à la racine du domaine (vérifications de domaine). + pub public_root_dir: PathBuf, + /// Racine des fichiers servis sous /uploads. + pub uploads_dir: PathBuf, + pub audio_upload_dir: PathBuf, + pub temp_upload_dir: PathBuf, + /// Polices (DejaVuSans.ttf) pour le texte incrusté dans les images. + pub fonts_dir: PathBuf, + pub gemini_api_key: Option, + pub external_api_key: Option, + /// « remotion » (défaut) ou « ffmpeg ». + pub reel_renderer: String, + /// Service de rendu Remotion (conteneur Node dédié). + pub remotion_url: Option, + pub ffmpeg_api_url: Option, + pub ffmpeg_api_key: Option, +} + +fn env(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +impl Config { + pub fn from_env() -> anyhow::Result { + let cwd = std::env::current_dir()?; + let production = matches!( + env("NODE_ENV").or_else(|| env("APP_ENV")).as_deref(), + Some("production") + ); + + let session_secret = match env("SESSION_SECRET") { + Some(secret) => secret, + None if production => { + bail!("SESSION_SECRET non défini en production. Arrêt du serveur.") + } + None => { + tracing::warn!( + "⚠️ SESSION_SECRET non défini. Utilisation d'une clé aléatoire pour le développement." + ); + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) + } + }; + + let database_url = match env("DATABASE_URL") { + Some(url) => url, + None => bail!("DATABASE_URL must be set. Did you forget to provision a database?"), + }; + + let uploads_dir = cwd.join("uploads"); + let audio_upload_dir = env("AUDIO_UPLOAD_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| uploads_dir.join("audio")); + let temp_upload_dir = env("TEMP_UPLOAD_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| uploads_dir.join("temp")); + + let static_dir = env("STATIC_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| cwd.join("dist").join("public")); + + let fonts_dir = env("FONTS_DIR").map(PathBuf::from).unwrap_or_else(|| { + let local = cwd.join("server").join("fonts"); + if local.exists() { + local + } else { + cwd.join("fonts") + } + }); + + Ok(Self { + port: env("PORT").and_then(|p| p.parse().ok()).unwrap_or(5000), + database_url, + session_secret, + production, + app_url: env("APP_URL").map(|u| u.trim_end_matches('/').to_string()), + internal_app_url: env("INTERNAL_APP_URL").map(|u| u.trim_end_matches('/').to_string()), + static_dir, + public_root_dir: env("PUBLIC_ROOT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| cwd.join("public-root")), + uploads_dir, + audio_upload_dir, + temp_upload_dir, + fonts_dir, + gemini_api_key: env("GEMINI_API_KEY"), + external_api_key: env("EXTERNAL_API_KEY"), + reel_renderer: if env("REEL_RENDERER").as_deref() == Some("ffmpeg") { + "ffmpeg".into() + } else { + "remotion".into() + }, + remotion_url: env("REMOTION_RENDERER_URL").map(|u| u.trim_end_matches('/').to_string()), + ffmpeg_api_url: env("FFMPEG_API_URL"), + ffmpeg_api_key: env("FFMPEG_API_KEY"), + }) + } + + /// Lecture directe d'une variable optionnelle (services peu configurés). + pub fn var(name: &str) -> Option { + env(name) + } +} diff --git a/server/src/crypto.rs b/server/src/crypto.rs new file mode 100644 index 0000000..e64a224 --- /dev/null +++ b/server/src/crypto.rs @@ -0,0 +1,152 @@ +//! Chiffrement des jetons au repos (AES-256-GCM), compatible avec les valeurs +//! déjà écrites par l'ancien serveur : clé dérivée par scrypt du secret +//! (`ENCRYPTION_KEY`, sinon fichier persistant), IV de 16 octets, format +//! `iv:authTag:chiffré` en hexadécimal. + +use std::path::{Path, PathBuf}; + +use aes_gcm::aead::consts::U16; +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::aes::Aes256; +use aes_gcm::{AesGcm, Nonce}; +use once_cell::sync::OnceCell; +use rand::RngCore; + +use crate::config::Config; + +type Cipher = AesGcm; + +static KEY: OnceCell<[u8; 32]> = OnceCell::new(); + +const SALT: &[u8] = b"socialflow-salt"; +const DOCKER_KEY_DIR: &str = "/app/.encryption-key-data"; + +fn derive(secret: &str) -> [u8; 32] { + // Paramètres par défaut de crypto.scryptSync : N=16384, r=8, p=1 + let params = scrypt::Params::new(14, 8, 1, 32).expect("paramètres scrypt valides"); + let mut out = [0u8; 32]; + scrypt::scrypt(secret.as_bytes(), SALT, ¶ms, &mut out).expect("dérivation scrypt"); + out +} + +fn key_file_path() -> PathBuf { + let dir = if Path::new(DOCKER_KEY_DIR).exists() { + PathBuf::from(DOCKER_KEY_DIR) + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + dir.join(".encryption-key") +} + +/// Charge (ou crée) la clé de chiffrement. Appelé une fois au démarrage. +pub fn init(_config: &Config) { + KEY.get_or_init(load_key); +} + +fn load_key() -> [u8; 32] { + if let Some(secret) = Config::var("ENCRYPTION_KEY") { + return derive(&secret); + } + + let path = key_file_path(); + if let Ok(saved) = std::fs::read_to_string(&path) { + let saved = saved.trim(); + if !saved.is_empty() { + tracing::info!("🔐 Clé de chiffrement chargée depuis le fichier persistant"); + return derive(saved); + } + } + + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + let secret = hex::encode(bytes); + match write_private(&path, &secret) { + Ok(()) => tracing::info!("🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement"), + Err(_) => tracing::warn!( + "⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage." + ), + } + derive(&secret) +} + +fn write_private(path: &Path, content: &str) -> std::io::Result<()> { + std::fs::write(path, content)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn key() -> &'static [u8; 32] { + KEY.get_or_init(load_key) +} + +pub fn encrypt(text: &str) -> String { + let cipher = Cipher::new_from_slice(key()).expect("clé de 32 octets"); + let mut iv = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut iv); + let sealed = cipher + .encrypt(Nonce::::from_slice(&iv), Payload { msg: text.as_bytes(), aad: &[] }) + .expect("chiffrement AES-GCM"); + // `sealed` = chiffré || tag (16 octets) + let (ciphertext, tag) = sealed.split_at(sealed.len() - 16); + format!("{}:{}:{}", hex::encode(iv), hex::encode(tag), hex::encode(ciphertext)) +} + +/// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en +/// clair avant le chiffrement) est rendue telle quelle. +pub fn decrypt(value: &str) -> String { + if !value.contains(':') { + return value.to_string(); + } + let parts: Vec<&str> = value.split(':').collect(); + if parts.len() != 3 { + return value.to_string(); + } + let attempt = || -> Option { + let iv = hex::decode(parts[0]).ok()?; + let tag = hex::decode(parts[1]).ok()?; + let mut data = hex::decode(parts[2]).ok()?; + if iv.len() != 16 || tag.len() != 16 { + return None; + } + data.extend_from_slice(&tag); + let cipher = Cipher::new_from_slice(key()).ok()?; + let plain = cipher + .decrypt(Nonce::::from_slice(&iv), Payload { msg: &data, aad: &[] }) + .ok()?; + String::from_utf8(plain).ok() + }; + match attempt() { + Some(plain) => plain, + None => { + tracing::warn!("⚠️ Échec du déchiffrement, token probablement non chiffré"); + value.to_string() + } + } +} + +#[allow(dead_code)] +pub fn is_encrypted(text: &str) -> bool { + let parts: Vec<&str> = text.split(':').collect(); + parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let _ = KEY.set(derive("test-secret")); + let sealed = encrypt("EAAB-token"); + assert!(is_encrypted(&sealed)); + assert_eq!(decrypt(&sealed), "EAAB-token"); + assert_eq!(decrypt("plain-token"), "plain-token"); + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs new file mode 100644 index 0000000..b40a61a --- /dev/null +++ b/server/src/db/mod.rs @@ -0,0 +1,1238 @@ +//! Accès aux données (portage de `storage.ts`). +//! +//! Les jetons des pages et les secrets des applications Facebook/TikTok sont +//! chiffrés au repos ; les fonctions de ce module les rendent déchiffrés. + +pub mod models; + +use std::collections::HashMap; +use std::time::Duration; + +use serde_json::Value; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{Executor, PgPool, Postgres, QueryBuilder, Row}; + +pub use models::*; + +use crate::crypto::{decrypt, encrypt}; +use crate::time::Ts; + +/// Statuts de publication TikTok définitifs : inutile d'interroger l'API au-delà. +pub const TERMINAL_PUBLISH_STATUSES: [&str; 3] = ["PUBLISH_COMPLETE", "FAILED", "SEND_TO_USER_INBOX"]; + +pub async fn connect(url: &str) -> Result { + let options: PgConnectOptions = url.parse()?; + PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(Duration::from_secs(30)) + .after_connect(|conn, _meta| { + Box::pin(async move { + // Les colonnes `timestamp` contiennent de l'UTC + conn.execute("SET TIME ZONE 'UTC'").await?; + Ok(()) + }) + }) + .connect_with(options) + .await +} + +/// Valeur à écrire dans une colonne, pour les mises à jour partielles. +#[derive(Debug, Clone)] +pub enum Val { + Text(Option), + Int(Option), + Time(Option), + Json(Option), + /// Valeur d'un type énuméré PostgreSQL : (type, valeur). + Enum(&'static str, Option), + /// Expression SQL brute (ex. `now()`). + Raw(&'static str), +} + +impl From for Val { + fn from(v: String) -> Self { + Val::Text(Some(v)) + } +} +impl From<&str> for Val { + fn from(v: &str) -> Self { + Val::Text(Some(v.to_string())) + } +} +impl From> for Val { + fn from(v: Option) -> Self { + Val::Text(v) + } +} +impl From for Val { + fn from(v: i32) -> Self { + Val::Int(Some(v)) + } +} +impl From> for Val { + fn from(v: Option) -> Self { + Val::Int(v) + } +} +impl From for Val { + fn from(v: Ts) -> Self { + Val::Time(Some(v)) + } +} +impl From> for Val { + fn from(v: Option) -> Self { + Val::Time(v) + } +} +impl From for Val { + fn from(v: Value) -> Self { + Val::Json(Some(v)) + } +} +impl From for Val { + fn from(v: TokenStatus) -> Self { + Val::Enum(TokenStatus::PG_TYPE, Some(v.as_str().into())) + } +} +impl From for Val { + fn from(v: PostStatus) -> Self { + Val::Enum(PostStatus::PG_TYPE, Some(v.as_str().into())) + } +} +impl From for Val { + fn from(v: PostType) -> Self { + Val::Enum(PostType::PG_TYPE, Some(v.as_str().into())) + } +} +impl From for Val { + fn from(v: UserRole) -> Self { + Val::Enum(UserRole::PG_TYPE, Some(v.as_str().into())) + } +} +impl From for Val { + fn from(v: Platform) -> Self { + Val::Enum(Platform::PG_TYPE, Some(v.as_str().into())) + } +} + +/// Mise à jour partielle : liste ordonnée de (colonne, valeur). +/// +/// ```ignore +/// db.update_scheduled_post(&id, Patch::new().set("published_at", Ts::now()).set("error", None::)).await?; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Patch { + sets: Vec<(&'static str, Val)>, +} + +impl Patch { + pub fn new() -> Self { + Self::default() + } + + pub fn set(mut self, column: &'static str, value: impl Into) -> Self { + self.sets.retain(|(c, _)| *c != column); + self.sets.push((column, value.into())); + self + } + + pub fn set_null(self, column: &'static str) -> Self { + self.set(column, Val::Text(None)) + } + + pub fn is_empty(&self) -> bool { + self.sets.is_empty() + } + + pub fn has(&self, column: &str) -> bool { + self.sets.iter().any(|(c, _)| *c == column) + } + + fn map_text(mut self, column: &str, f: impl Fn(&str) -> String) -> Self { + for (c, v) in self.sets.iter_mut() { + if *c == column { + if let Val::Text(Some(text)) = v { + *text = f(text); + } + } + } + self + } + + fn push_into(&self, qb: &mut QueryBuilder<'_, Postgres>) { + let mut first = true; + for (column, value) in &self.sets { + if !first { + qb.push(", "); + } + first = false; + qb.push(format!("\"{column}\" = ")); + match value.clone() { + Val::Text(v) => { + qb.push_bind(v); + } + Val::Int(v) => { + qb.push_bind(v); + } + Val::Time(v) => { + qb.push_bind(v); + } + Val::Json(v) => { + qb.push_bind(v); + } + Val::Enum(ty, v) => { + qb.push_bind(v); + qb.push(format!("::{ty}")); + } + Val::Raw(sql) => { + qb.push(sql); + } + } + } + } +} + +/// Exécute `UPDATE table SET … WHERE id = $id RETURNING *`. +async fn update_returning(pool: &PgPool, table: &str, id: &str, patch: &Patch) -> Result, sqlx::Error> +where + T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin, +{ + if patch.is_empty() { + let sql = format!("SELECT * FROM \"{table}\" WHERE id = $1"); + return sqlx::query_as::<_, T>(&sql).bind(id).fetch_optional(pool).await; + } + let mut qb = QueryBuilder::::new(format!("UPDATE \"{table}\" SET ")); + patch.push_into(&mut qb); + qb.push(" WHERE id = "); + qb.push_bind(id.to_string()); + qb.push(" RETURNING *"); + qb.build_query_as::().fetch_optional(pool).await +} + +fn decrypt_page(mut page: SocialPage) -> SocialPage { + page.access_token = decrypt(&page.access_token); + page.refresh_token = page.refresh_token.map(|t| decrypt(&t)); + page +} + +/// Données d'une nouvelle page (jetons en clair : chiffrés à l'écriture). +#[derive(Debug, Clone, Default)] +pub struct NewSocialPage { + pub user_id: String, + pub platform: Option, + pub page_id: String, + pub page_name: String, + pub access_token: String, + pub token_expires_at: Option, + pub followers_count: Option, + pub is_active: Option, + pub user_access_token: Option, + pub user_token_expires_at: Option, + pub refresh_token: Option, + pub refresh_token_expires_at: Option, + pub scopes: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct NewMedia { + pub user_id: String, + pub media_type: Option, + pub cloudinary_public_id: Option, + pub original_url: String, + pub facebook_landscape_url: Option, + pub facebook_feed_url: Option, + pub instagram_feed_url: Option, + pub instagram_story_url: Option, + pub thumbnail_url: Option, + pub file_name: String, + pub file_size: i32, +} + +#[derive(Debug, Clone)] +pub struct NewPost { + pub user_id: String, + pub content: String, + pub ai_generated: String, + pub product_info: Option, + pub status: PostStatus, + pub scheduled_for: Option, + pub generation_status: Option, + pub generation_progress: Option, + pub generation_error: Option, +} + +impl NewPost { + pub fn new(user_id: impl Into, content: impl Into) -> Self { + Self { + user_id: user_id.into(), + content: content.into(), + ai_generated: "false".into(), + product_info: None, + status: PostStatus::Draft, + scheduled_for: None, + generation_status: None, + generation_progress: None, + generation_error: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct NewScheduledPost { + pub post_id: String, + pub page_id: String, + pub post_type: PostType, + pub scheduled_at: Ts, + pub publish_id: Option, + pub publish_status: Option, +} + +impl NewScheduledPost { + pub fn new(post_id: impl Into, page_id: impl Into, post_type: PostType, scheduled_at: Ts) -> Self { + Self { + post_id: post_id.into(), + page_id: page_id.into(), + post_type, + scheduled_at, + publish_id: None, + publish_status: None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct NewMusicFavorite { + pub user_id: String, + pub track_id: String, + pub title: String, + pub artist: String, + pub album_name: Option, + pub duration: i32, + pub preview_url: String, + pub download_url: String, + pub image_url: Option, + pub license: Option, +} + +/// Compteurs du tableau de bord. +#[derive(Debug, Clone, Default)] +pub struct DashboardStats { + pub scheduled_posts: i64, + pub scheduled_posts_last_month: i64, + pub connected_pages: i64, + pub ai_texts_generated: i64, + pub ai_texts_yesterday: i64, + pub media_stored: i64, +} + +/// Job de rendu de Reel en cours, pour l'agenda. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct RenderingReelJob { + pub id: String, + pub post_id: Option, + pub created_at: Ts, + pub params: Value, + pub content: Option, +} + +#[derive(Clone)] +pub struct Db { + pub pool: PgPool, +} + +impl Db { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + // ------------------------------------------------------------------ + // Utilisateurs + // ------------------------------------------------------------------ + + pub async fn get_user(&self, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM users WHERE id = $1").bind(id).fetch_optional(&self.pool).await + } + + pub async fn get_user_by_username(&self, username: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_optional(&self.pool) + .await + } + + pub async fn get_all_users(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM users").fetch_all(&self.pool).await + } + + /// `password` doit déjà être haché. + pub async fn create_user(&self, username: &str, password_hash: &str, role: UserRole) -> Result { + sqlx::query_as("INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING *") + .bind(username) + .bind(password_hash) + .bind(role) + .fetch_one(&self.pool) + .await + } + + pub async fn update_user(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + update_returning(&self.pool, "users", id, &patch).await + } + + pub async fn delete_user(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM users WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + pub async fn get_dashboard_stats( + &self, + user_id: &str, + yesterday_start: Ts, + yesterday_end: Ts, + last_month: Ts, + ) -> Result { + let row = sqlx::query( + r#"SELECT + (SELECT count(*) FROM posts WHERE user_id = $1 AND status = 'scheduled') AS scheduled, + (SELECT count(*) FROM posts WHERE user_id = $1 AND status = 'scheduled' AND created_at < $4) AS scheduled_last_month, + (SELECT count(*) FROM ai_generations WHERE user_id = $1) AS ai_total, + (SELECT count(*) FROM ai_generations WHERE user_id = $1 AND created_at >= $2 AND created_at < $3) AS ai_yesterday, + (SELECT count(*) FROM social_pages WHERE user_id = $1) AS pages, + (SELECT count(*) FROM media WHERE user_id = $1) AS media"#, + ) + .bind(user_id) + .bind(yesterday_start) + .bind(yesterday_end) + .bind(last_month) + .fetch_one(&self.pool) + .await?; + Ok(DashboardStats { + scheduled_posts: row.try_get("scheduled")?, + scheduled_posts_last_month: row.try_get("scheduled_last_month")?, + connected_pages: row.try_get("pages")?, + ai_texts_generated: row.try_get("ai_total")?, + ai_texts_yesterday: row.try_get("ai_yesterday")?, + media_stored: row.try_get("media")?, + }) + } + + // ------------------------------------------------------------------ + // Pages sociales + // ------------------------------------------------------------------ + + pub async fn get_social_pages(&self, user_id: &str) -> Result, sqlx::Error> { + let pages: Vec = sqlx::query_as("SELECT * FROM social_pages WHERE user_id = $1") + .bind(user_id) + .fetch_all(&self.pool) + .await?; + Ok(pages.into_iter().map(decrypt_page).collect()) + } + + pub async fn get_all_social_pages(&self) -> Result, sqlx::Error> { + let pages: Vec = sqlx::query_as("SELECT * FROM social_pages").fetch_all(&self.pool).await?; + Ok(pages.into_iter().map(decrypt_page).collect()) + } + + pub async fn get_social_page(&self, id: &str) -> Result, sqlx::Error> { + let page: Option = sqlx::query_as("SELECT * FROM social_pages WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(page.map(decrypt_page)) + } + + pub async fn create_social_page(&self, page: NewSocialPage) -> Result { + let created: SocialPage = sqlx::query_as( + r#"INSERT INTO social_pages ( + user_id, platform, page_id, page_name, access_token, token_expires_at, + followers_count, is_active, user_access_token, user_token_expires_at, + refresh_token, refresh_token_expires_at, scopes, avatar_url + ) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, 'true'), $9, $10, $11, $12, $13, $14) + RETURNING *"#, + ) + .bind(&page.user_id) + .bind(page.platform.unwrap_or(Platform::Facebook)) + .bind(&page.page_id) + .bind(&page.page_name) + .bind(encrypt(&page.access_token)) + .bind(page.token_expires_at) + .bind(page.followers_count) + .bind(page.is_active) + .bind(page.user_access_token) + .bind(page.user_token_expires_at) + .bind(page.refresh_token.filter(|t| !t.is_empty()).map(|t| encrypt(&t))) + .bind(page.refresh_token_expires_at) + .bind(page.scopes) + .bind(page.avatar_url) + .fetch_one(&self.pool) + .await?; + Ok(decrypt_page(created)) + } + + /// Mise à jour partielle ; `access_token` et `refresh_token` sont chiffrés + /// ici (une valeur vide n'est pas chiffrée, comme avant). + pub async fn update_social_page(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + let patch = patch + .map_text("access_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) }) + .map_text("refresh_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) }); + let updated: Option = update_returning(&self.pool, "social_pages", id, &patch).await?; + Ok(updated.map(decrypt_page)) + } + + pub async fn delete_social_page(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM social_pages WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + // ------------------------------------------------------------------ + // Médias + // ------------------------------------------------------------------ + + pub async fn get_media(&self, user_id: &str, limit: Option, offset: Option) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT * FROM media WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset.unwrap_or(0)) + .fetch_all(&self.pool) + .await + } + + pub async fn get_media_by_ids(&self, ids: &[String]) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(Vec::new()); + } + sqlx::query_as("SELECT * FROM media WHERE id = ANY($1)").bind(ids).fetch_all(&self.pool).await + } + + pub async fn get_media_count(&self, user_id: &str) -> Result { + sqlx::query_scalar("SELECT count(*) FROM media WHERE user_id = $1") + .bind(user_id) + .fetch_one(&self.pool) + .await + } + + pub async fn get_media_by_id(&self, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM media WHERE id = $1").bind(id).fetch_optional(&self.pool).await + } + + pub async fn create_media(&self, media: NewMedia) -> Result { + sqlx::query_as( + r#"INSERT INTO media ( + user_id, type, cloudinary_public_id, original_url, facebook_landscape_url, + facebook_feed_url, instagram_feed_url, instagram_story_url, thumbnail_url, + file_name, file_size + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"#, + ) + .bind(&media.user_id) + .bind(media.media_type.unwrap_or(MediaType::Image)) + .bind(media.cloudinary_public_id) + .bind(media.original_url) + .bind(media.facebook_landscape_url) + .bind(media.facebook_feed_url) + .bind(media.instagram_feed_url) + .bind(media.instagram_story_url) + .bind(media.thumbnail_url) + .bind(media.file_name) + .bind(media.file_size) + .fetch_one(&self.pool) + .await + } + + pub async fn update_media(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + update_returning(&self.pool, "media", id, &patch).await + } + + pub async fn delete_media(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM media WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + pub async fn get_post_media(&self, post_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM post_media WHERE post_id = $1 ORDER BY display_order ASC") + .bind(post_id) + .fetch_all(&self.pool) + .await + } + + // ------------------------------------------------------------------ + // Posts + // ------------------------------------------------------------------ + + pub async fn get_posts(&self, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC") + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + pub async fn get_post(&self, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await + } + + /// Post et médias, dans l'ordre d'affichage. + pub async fn get_post_with_media(&self, id: &str) -> Result)>, sqlx::Error> { + let Some(post) = self.get_post(id).await? else { + return Ok(None); + }; + let links = self.get_post_media(id).await?; + let ids: Vec = links.iter().map(|l| l.media_id.clone()).collect(); + let by_id: HashMap = + self.get_media_by_ids(&ids).await?.into_iter().map(|m| (m.id.clone(), m)).collect(); + let media = links.iter().filter_map(|l| by_id.get(&l.media_id).cloned()).collect(); + Ok(Some((post, media))) + } + + pub async fn create_post(&self, post: NewPost) -> Result { + sqlx::query_as( + r#"INSERT INTO posts ( + user_id, content, ai_generated, product_info, status, scheduled_for, + generation_status, generation_progress, generation_error + ) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, 0), $9) RETURNING *"#, + ) + .bind(post.user_id) + .bind(post.content) + .bind(post.ai_generated) + .bind(post.product_info) + .bind(post.status) + .bind(post.scheduled_for) + .bind(post.generation_status) + .bind(post.generation_progress) + .bind(post.generation_error) + .fetch_one(&self.pool) + .await + } + + /// Mise à jour partielle ; `updated_at` est toujours renouvelé. + pub async fn update_post(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + let patch = patch.set("updated_at", Ts::now()); + update_returning(&self.pool, "posts", id, &patch).await + } + + pub async fn update_post_generation_status( + &self, + id: &str, + status: &str, + progress: i32, + error: Option<&str>, + ) -> Result, sqlx::Error> { + let mut patch = Patch::new().set("generation_status", status).set("generation_progress", progress); + if let Some(error) = error { + patch = patch.set("generation_error", error); + } + self.update_post(id, patch).await + } + + pub async fn delete_post(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM posts WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + /// Remplace les médias d'un post (ordre = position dans la liste). + pub async fn update_post_media(&self, post_id: &str, media_ids: &[String]) -> Result<(), sqlx::Error> { + let items: Vec<(String, i32)> = + media_ids.iter().enumerate().map(|(i, id)| (id.clone(), i as i32)).collect(); + self.set_post_media(post_id, &items, true).await + } + + /// Lie des médias à un post avec un ordre explicite ; `replace` efface d'abord les liens existants. + pub async fn set_post_media(&self, post_id: &str, items: &[(String, i32)], replace: bool) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + if replace { + sqlx::query("DELETE FROM post_media WHERE post_id = $1").bind(post_id).execute(&mut *tx).await?; + } + for (media_id, order) in items { + sqlx::query("INSERT INTO post_media (post_id, media_id, display_order) VALUES ($1, $2, $3)") + .bind(post_id) + .bind(media_id) + .bind(order) + .execute(&mut *tx) + .await?; + } + tx.commit().await + } + + // ------------------------------------------------------------------ + // Publications planifiées + // ------------------------------------------------------------------ + + pub async fn get_scheduled_posts( + &self, + user_id: &str, + start: Option, + end: Option, + ) -> Result, sqlx::Error> { + self.query_scheduled_posts("p.user_id = $1", ScopeBind::Text(user_id.to_string()), start, end).await + } + + pub async fn get_scheduled_posts_by_pages( + &self, + page_ids: &[String], + start: Option, + end: Option, + ) -> Result, sqlx::Error> { + if page_ids.is_empty() { + return Ok(Vec::new()); + } + self.query_scheduled_posts("sp.page_id = ANY($1)", ScopeBind::List(page_ids.to_vec()), start, end).await + } + + async fn query_scheduled_posts( + &self, + scope: &str, + bind: ScopeBind, + start: Option, + end: Option, + ) -> Result, sqlx::Error> { + let sql = format!( + r#"SELECT sp.*, + p.id AS p_id, p.user_id AS p_user_id, p.content AS p_content, p.status AS p_status, + p.scheduled_for AS p_scheduled_for, p.ai_generated AS p_ai_generated, + p.generation_status AS p_generation_status, p.generation_progress AS p_generation_progress, + p.created_at AS p_created_at, + pg.id AS pg_id, pg.page_id AS pg_page_id, pg.page_name AS pg_page_name, + pg.platform AS pg_platform, pg.avatar_url AS pg_avatar_url + FROM scheduled_posts sp + INNER JOIN posts p ON sp.post_id = p.id + LEFT JOIN social_pages pg ON sp.page_id = pg.id + WHERE {scope} + AND ($2::timestamp IS NULL OR sp.scheduled_at >= $2) + AND ($3::timestamp IS NULL OR sp.scheduled_at <= $3) + ORDER BY sp.scheduled_at DESC"# + ); + let query = sqlx::query(&sql); + let query = match bind { + ScopeBind::Text(v) => query.bind(v), + ScopeBind::List(v) => query.bind(v), + }; + let rows = query.bind(start).bind(end).fetch_all(&self.pool).await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let scheduled = >::from_row(&row)?; + let post = ScheduledPostPost { + id: row.try_get("p_id")?, + user_id: row.try_get("p_user_id")?, + content: row.try_get("p_content")?, + status: row.try_get("p_status")?, + scheduled_for: row.try_get("p_scheduled_for")?, + ai_generated: row.try_get("p_ai_generated")?, + generation_status: row.try_get("p_generation_status")?, + generation_progress: row.try_get("p_generation_progress")?, + created_at: row.try_get("p_created_at")?, + }; + let page_id: Option = row.try_get("pg_id")?; + let page = match page_id { + Some(id) => Some(ScheduledPostPage { + id, + page_id: row.try_get("pg_page_id")?, + page_name: row.try_get("pg_page_name")?, + platform: row.try_get("pg_platform")?, + avatar_url: row.try_get("pg_avatar_url")?, + }), + None => None, + }; + out.push(ScheduledPostWithDetails { scheduled, post, page }); + } + Ok(out) + } + + pub async fn get_scheduled_post(&self, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM scheduled_posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await + } + + pub async fn get_scheduled_posts_by_post(&self, post_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM scheduled_posts WHERE post_id = $1") + .bind(post_id) + .fetch_all(&self.pool) + .await + } + + pub async fn create_scheduled_post(&self, sp: NewScheduledPost) -> Result { + sqlx::query_as( + r#"INSERT INTO scheduled_posts (post_id, page_id, post_type, scheduled_at, publish_id, publish_status) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"#, + ) + .bind(sp.post_id) + .bind(sp.page_id) + .bind(sp.post_type) + .bind(sp.scheduled_at) + .bind(sp.publish_id) + .bind(sp.publish_status) + .fetch_one(&self.pool) + .await + } + + pub async fn update_scheduled_post(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + update_returning(&self.pool, "scheduled_posts", id, &patch).await + } + + pub async fn delete_scheduled_post(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM scheduled_posts WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + /// Publications dues et pas encore traitées. + pub async fn get_pending_scheduled_posts(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM scheduled_posts WHERE published_at IS NULL AND scheduled_at <= $1") + .bind(Ts::now()) + .fetch_all(&self.pool) + .await + } + + /// Envois TikTok dont le statut final n'est pas encore connu. + pub async fn get_scheduled_posts_awaiting_publish_status(&self) -> Result, sqlx::Error> { + // `NOT IN` ignore les statuts NULL, comme `notInArray` de Drizzle + sqlx::query_as( + "SELECT * FROM scheduled_posts WHERE publish_id IS NOT NULL AND publish_status NOT IN ($1, $2, $3)", + ) + .bind(TERMINAL_PUBLISH_STATUSES[0]) + .bind(TERMINAL_PUBLISH_STATUSES[1]) + .bind(TERMINAL_PUBLISH_STATUSES[2]) + .fetch_all(&self.pool) + .await + } + + /// Reels en cours de génération (et échecs des dernières 24 h), avec l'étape du rendu. + pub async fn get_ongoing_reel_posts(&self, user_id: &str) -> Result, sqlx::Error> { + let since = Ts::now().add(chrono::Duration::hours(-24)); + let posts: Vec = sqlx::query_as( + r#"SELECT * FROM posts + WHERE user_id = $1 + AND (generation_status IN ('processing', 'pending') + OR (generation_status = 'failed' AND updated_at >= $2 AND generation_error IS NOT NULL)) + ORDER BY created_at DESC"#, + ) + .bind(user_id) + .bind(since) + .fetch_all(&self.pool) + .await?; + if posts.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = posts.iter().map(|p| p.id.clone()).collect(); + let steps: Vec<(Option, Option)> = + sqlx::query_as("SELECT post_id, step FROM reel_jobs WHERE post_id = ANY($1)") + .bind(&ids) + .fetch_all(&self.pool) + .await?; + let step_by_post: HashMap> = + steps.into_iter().filter_map(|(post_id, step)| post_id.map(|p| (p, step))).collect(); + Ok(posts + .into_iter() + .map(|post| { + let step = step_by_post.get(&post.id).cloned().flatten(); + let mut value = serde_json::to_value(&post).unwrap_or(Value::Null); + if let Value::Object(map) = &mut value { + map.insert("generationStep".into(), step.map(Value::String).unwrap_or(Value::Null)); + } + value + }) + .collect()) + } + + /// Reels vidéo en file ou en montage (leurs publications n'existent qu'après le rendu). + pub async fn get_rendering_reel_jobs(&self) -> Result, sqlx::Error> { + sqlx::query_as( + r#"SELECT j.id, j.post_id, j.created_at, j.params, p.content + FROM reel_jobs j LEFT JOIN posts p ON j.post_id = p.id + WHERE j.kind = 'video' AND j.status IN ('pending', 'processing')"#, + ) + .fetch_all(&self.pool) + .await + } + + // ------------------------------------------------------------------ + // Générations IA + // ------------------------------------------------------------------ + + pub async fn get_ai_generations(&self, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM ai_generations WHERE user_id = $1 ORDER BY created_at DESC") + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + pub async fn create_ai_generation( + &self, + user_id: &str, + product_info: &Value, + generated_texts: &Value, + ) -> Result { + sqlx::query_as( + "INSERT INTO ai_generations (user_id, product_info, generated_texts) VALUES ($1, $2, $3) RETURNING *", + ) + .bind(user_id) + .bind(product_info) + .bind(generated_texts) + .fetch_one(&self.pool) + .await + } + + // ------------------------------------------------------------------ + // Configuration du stockage (ex-Cloudinary, désormais local) et logo + // ------------------------------------------------------------------ + + pub async fn get_cloudinary_config(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1").fetch_optional(&self.pool).await + } + + pub async fn create_cloudinary_config( + &self, + cloud_name: &str, + api_key: &str, + api_secret: &str, + logo_public_id: Option<&str>, + endpoint_url: Option<&str>, + public_url: Option<&str>, + ) -> Result { + sqlx::query_as( + r#"INSERT INTO cloudinary_config (cloud_name, api_key, api_secret, logo_public_id, endpoint_url, public_url) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"#, + ) + .bind(cloud_name) + .bind(api_key) + .bind(api_secret) + .bind(logo_public_id) + .bind(endpoint_url) + .bind(public_url) + .fetch_one(&self.pool) + .await + } + + /// Met à jour la configuration existante (erreur si elle n'existe pas). + pub async fn update_cloudinary_config(&self, patch: Patch) -> anyhow::Result { + let existing = self + .get_cloudinary_config() + .await? + .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; + let patch = patch.set("updated_at", Ts::now()); + let updated = update_returning::(&self.pool, "cloudinary_config", &existing.id, &patch) + .await? + .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; + Ok(updated) + } + + // ------------------------------------------------------------------ + // OpenRouter + // ------------------------------------------------------------------ + + pub async fn get_openrouter_config(&self, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM openrouter_config WHERE user_id = $1") + .bind(user_id) + .fetch_optional(&self.pool) + .await + } + + pub async fn get_any_openrouter_config(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM openrouter_config ORDER BY updated_at DESC NULLS LAST LIMIT 1") + .fetch_optional(&self.pool) + .await + } + + /// `model` / `system_prompt` absents : valeurs par défaut de la table. + pub async fn create_openrouter_config( + &self, + user_id: &str, + api_key: &str, + model: Option<&str>, + system_prompt: Option<&str>, + ) -> Result { + sqlx::query_as( + r#"INSERT INTO openrouter_config (user_id, api_key, model, system_prompt) + VALUES ($1, $2, + COALESCE($3, 'anthropic/claude-sonnet-4.5'), + COALESCE($4, 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.')) + RETURNING *"#, + ) + .bind(user_id) + .bind(api_key) + .bind(model) + .bind(system_prompt) + .fetch_one(&self.pool) + .await + } + + pub async fn update_openrouter_config(&self, user_id: &str, patch: Patch) -> Result, sqlx::Error> { + let patch = patch.set("updated_at", Ts::now()); + let mut qb = QueryBuilder::::new("UPDATE openrouter_config SET "); + patch.push_into(&mut qb); + qb.push(" WHERE user_id = "); + qb.push_bind(user_id.to_string()); + qb.push(" RETURNING *"); + qb.build_query_as().fetch_optional(&self.pool).await + } + + // ------------------------------------------------------------------ + // Permissions des utilisateurs sur les pages + // ------------------------------------------------------------------ + + pub async fn get_user_page_permissions(&self, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM user_page_permissions WHERE user_id = $1") + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + pub async fn get_page_permissions(&self, page_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM user_page_permissions WHERE page_id = $1") + .bind(page_id) + .fetch_all(&self.pool) + .await + } + + pub async fn create_page_permission(&self, user_id: &str, page_id: &str) -> Result { + sqlx::query_as("INSERT INTO user_page_permissions (user_id, page_id) VALUES ($1, $2) RETURNING *") + .bind(user_id) + .bind(page_id) + .fetch_one(&self.pool) + .await + } + + pub async fn delete_page_permission(&self, user_id: &str, page_id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM user_page_permissions WHERE user_id = $1 AND page_id = $2") + .bind(user_id) + .bind(page_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn delete_all_user_page_permissions(&self, user_id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM user_page_permissions WHERE user_id = $1") + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Pages attribuées à un utilisateur (jetons déchiffrés). + pub async fn get_user_accessible_pages(&self, user_id: &str) -> Result, sqlx::Error> { + let pages: Vec = sqlx::query_as( + r#"SELECT sp.* FROM user_page_permissions upp + INNER JOIN social_pages sp ON upp.page_id = sp.id + WHERE upp.user_id = $1"#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + Ok(pages.into_iter().map(decrypt_page).collect()) + } + + /// Pages visibles par l'utilisateur : toutes pour un admin, sinon celles attribuées. + pub async fn get_visible_pages(&self, user: &User) -> Result, sqlx::Error> { + if user.is_admin() { + self.get_all_social_pages().await + } else { + self.get_user_accessible_pages(&user.id).await + } + } + + // ------------------------------------------------------------------ + // Musique + // ------------------------------------------------------------------ + + pub async fn get_music_favorites(&self, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM music_favorites WHERE user_id = $1 ORDER BY created_at DESC") + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + pub async fn add_music_favorite(&self, fav: NewMusicFavorite) -> Result { + sqlx::query_as( + r#"INSERT INTO music_favorites (user_id, track_id, title, artist, album_name, duration, + preview_url, download_url, image_url, license) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *"#, + ) + .bind(fav.user_id) + .bind(fav.track_id) + .bind(fav.title) + .bind(fav.artist) + .bind(fav.album_name) + .bind(fav.duration) + .bind(fav.preview_url) + .bind(fav.download_url) + .bind(fav.image_url) + .bind(fav.license) + .fetch_one(&self.pool) + .await + } + + pub async fn remove_music_favorite(&self, user_id: &str, track_id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM music_favorites WHERE user_id = $1 AND track_id = $2") + .bind(user_id) + .bind(track_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn is_music_favorite(&self, user_id: &str, track_id: &str) -> Result { + let found: Option = + sqlx::query_scalar("SELECT id FROM music_favorites WHERE user_id = $1 AND track_id = $2 LIMIT 1") + .bind(user_id) + .bind(track_id) + .fetch_optional(&self.pool) + .await?; + Ok(found.is_some()) + } + + // ------------------------------------------------------------------ + // Pistes audio + // ------------------------------------------------------------------ + + pub async fn get_audio_tracks(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM audio_tracks ORDER BY created_at DESC").fetch_all(&self.pool).await + } + + pub async fn get_audio_track(&self, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1").bind(id).fetch_optional(&self.pool).await + } + + pub async fn create_audio_track( + &self, + user_id: Option<&str>, + title: &str, + file_name: &str, + url: &str, + duration: i32, + ) -> Result { + sqlx::query_as( + "INSERT INTO audio_tracks (user_id, title, file_name, url, duration) VALUES ($1, $2, $3, $4, $5) RETURNING *", + ) + .bind(user_id) + .bind(title) + .bind(file_name) + .bind(url) + .bind(duration) + .fetch_one(&self.pool) + .await + } + + pub async fn delete_audio_track(&self, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM audio_tracks WHERE id = $1").bind(id).execute(&self.pool).await?; + Ok(()) + } + + // ------------------------------------------------------------------ + // Configuration de l'application + // ------------------------------------------------------------------ + + pub async fn get_app_config(&self) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM app_config LIMIT 1").fetch_optional(&self.pool).await + } + + /// Colonnes possibles : external_api_key, gemini_api_key, qwen_tts_url, qwen_tts_api_key. + pub async fn upsert_app_config(&self, patch: Patch) -> Result { + if let Some(existing) = self.get_app_config().await? { + let patch = patch.clone().set("updated_at", Ts::now()); + if let Some(updated) = update_returning(&self.pool, "app_config", &existing.id, &patch).await? { + return Ok(updated); + } + } + let id: String = sqlx::query_scalar("INSERT INTO app_config DEFAULT VALUES RETURNING id") + .fetch_one(&self.pool) + .await?; + let patch = patch.set("updated_at", Ts::now()); + update_returning(&self.pool, "app_config", &id, &patch) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + + // ------------------------------------------------------------------ + // Applications développeur Facebook et TikTok (secrets chiffrés) + // ------------------------------------------------------------------ + + pub async fn get_facebook_config(&self) -> Result, sqlx::Error> { + let config: Option = + sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?; + Ok(config.map(|mut c| { + c.app_secret = decrypt(&c.app_secret); + c + })) + } + + /// Crée ou met à jour ; `app_secret` absent = secret inchangé. + pub async fn upsert_facebook_config(&self, app_id: Option<&str>, app_secret: Option<&str>) -> anyhow::Result { + let existing: Option = + sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?; + let secret = app_secret.filter(|s| !s.is_empty()).map(encrypt); + let mut row: FacebookConfig = match existing { + Some(existing) => { + let mut patch = Patch::new(); + if let Some(app_id) = app_id { + patch = patch.set("app_id", app_id); + } + if let Some(secret) = secret { + patch = patch.set("app_secret", secret); + } + let patch = patch.set("updated_at", Ts::now()); + update_returning(&self.pool, "facebook_config", &existing.id, &patch) + .await? + .ok_or_else(|| anyhow::anyhow!("configuration Facebook introuvable"))? + } + None => { + let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret) else { + anyhow::bail!("L'App ID et l'App Secret Facebook sont requis"); + }; + sqlx::query_as("INSERT INTO facebook_config (app_id, app_secret) VALUES ($1, $2) RETURNING *") + .bind(app_id) + .bind(secret) + .fetch_one(&self.pool) + .await? + } + }; + row.app_secret = decrypt(&row.app_secret); + Ok(row) + } + + pub async fn get_tiktok_config(&self) -> Result, sqlx::Error> { + let config: Option = + sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?; + Ok(config.map(|mut c| { + c.client_secret = decrypt(&c.client_secret); + c + })) + } + + /// Crée ou met à jour ; `client_secret` absent = secret inchangé. + pub async fn upsert_tiktok_config(&self, client_key: Option<&str>, client_secret: Option<&str>) -> anyhow::Result { + let existing: Option = + sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?; + let secret = client_secret.filter(|s| !s.is_empty()).map(encrypt); + let mut row: TiktokConfig = match existing { + Some(existing) => { + let mut patch = Patch::new(); + if let Some(key) = client_key { + patch = patch.set("client_key", key); + } + if let Some(secret) = secret { + patch = patch.set("client_secret", secret); + } + let patch = patch.set("updated_at", Ts::now()); + update_returning(&self.pool, "tiktok_config", &existing.id, &patch) + .await? + .ok_or_else(|| anyhow::anyhow!("configuration TikTok introuvable"))? + } + None => { + let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret) else { + anyhow::bail!("Le client key et le client secret TikTok sont requis"); + }; + sqlx::query_as("INSERT INTO tiktok_config (client_key, client_secret) VALUES ($1, $2) RETURNING *") + .bind(key) + .bind(secret) + .fetch_one(&self.pool) + .await? + } + }; + row.client_secret = decrypt(&row.client_secret); + Ok(row) + } +} + +enum ScopeBind { + Text(String), + List(Vec), +} diff --git a/server/src/db/models.rs b/server/src/db/models.rs new file mode 100644 index 0000000..1a687ff --- /dev/null +++ b/server/src/db/models.rs @@ -0,0 +1,371 @@ +//! Lignes de la base, sérialisées en camelCase comme les objets Drizzle que +//! renvoyait l'ancien serveur. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; + +use crate::time::Ts; + +macro_rules! pg_enum { + ($name:ident, $pg:literal, { $($variant:ident => $text:literal),+ $(,)? }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] + #[sqlx(type_name = $pg)] + pub enum $name { + $( + #[serde(rename = $text)] + #[sqlx(rename = $text)] + $variant, + )+ + } + + impl $name { + pub fn as_str(&self) -> &'static str { + match self { + $( $name::$variant => $text, )+ + } + } + + pub fn parse(value: &str) -> Option { + match value { + $( $text => Some($name::$variant), )+ + _ => None, + } + } + + /// Nom du type énuméré PostgreSQL (pour les conversions `$1::type`). + #[allow(dead_code)] + pub const PG_TYPE: &'static str = $pg; + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } + } + }; +} + +pg_enum!(Platform, "platform", { Facebook => "facebook", Instagram => "instagram", Tiktok => "tiktok" }); +pg_enum!(PostType, "post_type", { Feed => "feed", Story => "story", Both => "both", Reel => "reel" }); +pg_enum!(PostStatus, "post_status", { Draft => "draft", Scheduled => "scheduled", Published => "published", Failed => "failed" }); +pg_enum!(MediaType, "media_type", { Image => "image", Video => "video" }); +pg_enum!(UserRole, "user_role", { Admin => "admin", User => "user" }); +pg_enum!(TokenStatus, "token_status", { Valid => "valid", Expiring => "expiring", Expired => "expired", Error => "error" }); + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: String, + pub username: String, + #[serde(skip_serializing)] + pub password: String, + pub role: UserRole, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.role == UserRole::Admin + } +} + +/// Page ou compte connecté. Les jetons sont **déchiffrés** par la couche +/// d'accès aux données ; ils ne sont jamais sérialisés vers le navigateur. +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct SocialPage { + pub id: String, + pub user_id: String, + pub platform: Platform, + pub page_id: String, + pub page_name: String, + #[serde(skip_serializing)] + pub access_token: String, + pub token_expires_at: Option, + pub followers_count: Option, + pub is_active: String, + pub token_status: Option, + pub last_token_check: Option, + #[serde(skip_serializing)] + pub user_access_token: Option, + pub user_token_expires_at: Option, + pub token_error: Option, + #[serde(skip_serializing)] + pub refresh_token: Option, + pub refresh_token_expires_at: Option, + pub scopes: Option, + pub avatar_url: Option, + pub created_at: Option, +} + +impl SocialPage { + /// Vue client : sans jetons, avec l'indicateur de renouvellement automatique. + pub fn to_client(&self) -> Value { + let mut value = serde_json::to_value(self).unwrap_or(Value::Null); + let auto_renew = match self.platform { + Platform::Tiktok => self.refresh_token.as_deref().is_some_and(|t| !t.is_empty()), + _ => self.user_access_token.as_deref().is_some_and(|t| !t.is_empty()), + }; + if let Value::Object(map) = &mut value { + map.insert("autoRenew".into(), Value::Bool(auto_renew)); + } + value + } +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct Media { + pub id: String, + pub user_id: String, + #[serde(rename = "type")] + #[sqlx(rename = "type")] + pub media_type: MediaType, + pub cloudinary_public_id: Option, + pub original_url: String, + pub facebook_landscape_url: Option, + pub facebook_feed_url: Option, + pub instagram_feed_url: Option, + pub instagram_story_url: Option, + pub thumbnail_url: Option, + pub file_name: String, + pub file_size: i32, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct Post { + pub id: String, + pub user_id: String, + pub content: String, + pub ai_generated: String, + pub product_info: Option, + pub status: PostStatus, + pub scheduled_for: Option, + pub generation_status: Option, + pub generation_progress: Option, + pub generation_error: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct PostMedia { + pub id: String, + pub post_id: String, + pub media_id: String, + pub display_order: i32, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledPost { + pub id: String, + pub post_id: String, + pub page_id: String, + pub post_type: PostType, + pub scheduled_at: Ts, + pub published_at: Option, + pub external_post_id: Option, + pub error: Option, + pub publish_id: Option, + pub publish_status: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct AiGeneration { + pub id: String, + pub user_id: String, + pub product_info: Value, + pub generated_texts: Value, + pub selected_text: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct CloudinaryConfig { + pub id: String, + pub cloud_name: String, + pub api_key: String, + #[serde(skip_serializing)] + pub api_secret: String, + pub logo_public_id: Option, + pub endpoint_url: Option, + pub public_url: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct OpenrouterConfig { + pub id: String, + pub user_id: String, + #[serde(skip_serializing)] + pub api_key: String, + pub model: String, + pub system_prompt: String, + pub created_at: Option, + pub updated_at: Option, +} + +/// Application développeur Facebook (secret déchiffré). +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct FacebookConfig { + pub id: String, + pub app_id: String, + #[serde(skip_serializing)] + pub app_secret: String, + pub created_at: Option, + pub updated_at: Option, +} + +/// Application développeur TikTok (secret déchiffré). +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct TiktokConfig { + pub id: String, + pub client_key: String, + #[serde(skip_serializing)] + pub client_secret: String, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct MusicFavorite { + pub id: String, + pub user_id: String, + pub track_id: String, + pub title: String, + pub artist: String, + pub album_name: Option, + pub duration: i32, + pub preview_url: String, + pub download_url: String, + pub image_url: Option, + pub license: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + pub id: String, + pub external_api_key: Option, + pub gemini_api_key: Option, + pub qwen_tts_url: Option, + pub qwen_tts_api_key: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct AudioTrack { + pub id: String, + pub user_id: Option, + pub title: String, + pub file_name: String, + pub url: String, + pub duration: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct UserPagePermission { + pub id: String, + pub user_id: String, + pub page_id: String, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct ReelJob { + pub id: String, + pub user_id: String, + pub post_id: Option, + pub kind: String, + pub params: Value, + pub status: String, + pub step: Option, + pub progress: i32, + pub attempts: i32, + pub result: Option, + pub error: Option, + pub heartbeat_at: Option, + pub created_at: Ts, + pub updated_at: Ts, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct PostAnalytics { + pub id: String, + pub post_id: String, + pub fetched_at: Option, + pub impressions: Option, + pub reach: Option, + pub engagement: Option, + pub reactions: Option, + pub comments: Option, + pub shares: Option, + pub clicks: Option, + pub raw_data: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct PageAnalyticsHistory { + pub id: String, + pub page_id: String, + pub date: Ts, + pub followers_count: Option, + pub page_views: Option, + pub page_reach: Option, + pub page_engagement: Option, + pub created_at: Option, +} + +/// Publication planifiée accompagnée du post et de la page (sans jetons), +/// telle que la renvoyaient les routes du calendrier et de l'historique. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledPostWithDetails { + #[serde(flatten)] + pub scheduled: ScheduledPost, + pub post: ScheduledPostPost, + pub page: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledPostPost { + pub id: String, + pub user_id: String, + pub content: String, + pub status: PostStatus, + pub scheduled_for: Option, + pub ai_generated: String, + pub generation_status: Option, + pub generation_progress: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledPostPage { + pub id: String, + pub page_id: String, + pub page_name: String, + pub platform: Platform, + pub avatar_url: Option, +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..1f5a5c4 --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,99 @@ +//! Erreurs HTTP au format attendu par le client : `{ "error": "message" }`. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub body: Value, +} + +pub type ApiResult = Result; + +impl AppError { + /// Réponse `{ error: message }` avec le statut donné. + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, body: json!({ "error": message.into() }) } + } + + /// Corps JSON libre (ex. `{ success: false, error }`). + pub fn json(status: StatusCode, body: Value) -> Self { + Self { status, body } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } + + /// 500 avec un message fixe ; la cause est journalisée. + pub fn internal_logged(message: impl Into, cause: impl std::fmt::Display) -> Self { + let message = message.into(); + tracing::error!("{message}: {cause}"); + Self::internal(message) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, Json(self.body)).into_response() + } +} + +impl From for AppError { + fn from(error: sqlx::Error) -> Self { + AppError::internal_logged("Erreur de base de données", error) + } +} + +impl From for AppError { + fn from(error: anyhow::Error) -> Self { + tracing::error!("{error:#}"); + AppError::internal(error.to_string()) + } +} + +/// Raccourcis pour convertir une erreur en réponse 500 au message donné, +/// comme les `catch` des anciennes routes (`res.status(500).json({ error })`). +pub trait OrApiError { + /// Journalise la cause et renvoie `500 { error: message }`. + fn or_500(self, message: &str) -> Result; + /// Renvoie `500 { error: }`. + fn or_500_cause(self) -> Result; +} + +impl OrApiError for Result { + fn or_500(self, message: &str) -> Result { + self.map_err(|e| AppError::internal_logged(message, e)) + } + + fn or_500_cause(self) -> Result { + self.map_err(|e| { + let message = e.to_string(); + tracing::error!("{message}"); + AppError::internal(message) + }) + } +} diff --git a/server/src/extract.rs b/server/src/extract.rs new file mode 100644 index 0000000..482529f --- /dev/null +++ b/server/src/extract.rs @@ -0,0 +1,132 @@ +//! Extracteurs de requête. +//! +//! `Body` reproduit `express.json()` + `express.urlencoded()` : un corps +//! JSON est lu tel quel, un formulaire est converti en objet, et une requête +//! sans corps donne `{}`. Les erreurs sortent au format `{ error }`. + +use axum::body::Bytes; +use axum::extract::{FromRequest, FromRequestParts, Request}; +use axum::http::header::CONTENT_TYPE; +use axum::http::request::Parts; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::error::AppError; + +/// Taille maximale d'un corps JSON ou formulaire. +pub const MAX_JSON_BODY: usize = 25 * 1024 * 1024; + +pub struct Body(pub T); + +impl FromRequest for Body +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let content_type = req + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + let bytes = Bytes::from_request(req, state) + .await + .map_err(|e| AppError::new(e.status(), e.body_text()))?; + + let value = if bytes.iter().all(|b| b.is_ascii_whitespace()) { + Value::Object(Map::new()) + } else if content_type.contains("json") { + serde_json::from_slice::(&bytes) + .map_err(|e| AppError::bad_request(format!("JSON invalide : {e}")))? + } else if content_type.starts_with("application/x-www-form-urlencoded") { + let pairs: Vec<(String, String)> = serde_urlencoded_pairs(&bytes); + let mut map = Map::new(); + for (k, v) in pairs { + map.insert(k, Value::String(v)); + } + Value::Object(map) + } else { + Value::Object(Map::new()) + }; + + let parsed = serde_json::from_value::(value) + .map_err(|e| AppError::bad_request(format!("Données invalides : {e}")))?; + Ok(Body(parsed)) + } +} + +fn serde_urlencoded_pairs(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() +} + +/// Paramètres de requête bruts (`?a=1&b=2`), comme `req.query` (valeurs texte). +pub struct QueryMap(pub std::collections::HashMap); + +impl FromRequestParts for QueryMap { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let mut map = std::collections::HashMap::new(); + if let Some(q) = parts.uri.query() { + for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { + // Premier gagnant, comme une valeur simple d'Express + map.entry(k.into_owned()).or_insert_with(|| v.into_owned()); + } + } + Ok(QueryMap(map)) + } +} + +impl QueryMap { + pub fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).map(|s| s.as_str()) + } + + /// Valeur non vide. + pub fn non_empty(&self, key: &str) -> Option<&str> { + self.get(key).filter(|v| !v.is_empty()) + } +} + +/// Petits accesseurs pour lire un corps JSON dynamique comme en JavaScript. +pub trait JsonExt { + /// Chaîne (non vide ou non) si la valeur est une chaîne. + fn str_field(&self, key: &str) -> Option<&str>; + /// Chaîne non vide après `trim()`. + fn trimmed(&self, key: &str) -> Option; + /// Valeur « truthy » au sens JavaScript. + fn truthy(&self, key: &str) -> bool; +} + +impl JsonExt for Value { + fn str_field(&self, key: &str) -> Option<&str> { + self.get(key).and_then(Value::as_str) + } + + fn trimmed(&self, key: &str) -> Option { + self.str_field(key) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + } + + fn truthy(&self, key: &str) -> bool { + is_truthy(self.get(key)) + } +} + +pub fn is_truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} diff --git a/server/src/http.rs b/server/src/http.rs new file mode 100644 index 0000000..c01c61e --- /dev/null +++ b/server/src/http.rs @@ -0,0 +1,163 @@ +//! Intergiciels HTTP : en-têtes de sécurité (équivalent de helmet), limitation +//! de débit par IP et journal des appels `/api`. + +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use axum::extract::{ConnectInfo, Request, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::state::AppState; + +/// Fenêtre fixe par IP, comme express-rate-limit. +pub struct RateLimiter { + max: u32, + window: Duration, + hits: Mutex>, +} + +pub struct RateDecision { + pub allowed: bool, + pub limit: u32, + pub remaining: u32, + pub reset_secs: u64, +} + +impl RateLimiter { + pub fn new(max: u32, window: Duration) -> Self { + Self { max, window, hits: Mutex::new(HashMap::new()) } + } + + pub fn check(&self, ip: IpAddr) -> RateDecision { + let now = Instant::now(); + let mut hits = self.hits.lock().expect("verrou du limiteur"); + if hits.len() > 10_000 { + let window = self.window; + hits.retain(|_, (start, _)| now.duration_since(*start) < window); + } + let entry = hits.entry(ip).or_insert((now, 0)); + if now.duration_since(entry.0) >= self.window { + *entry = (now, 0); + } + entry.1 += 1; + let reset = self.window.saturating_sub(now.duration_since(entry.0)); + RateDecision { + allowed: entry.1 <= self.max, + limit: self.max, + remaining: self.max.saturating_sub(entry.1), + reset_secs: reset.as_secs(), + } + } +} + +/// IP du client : dernier saut de `X-Forwarded-For` (posé par Nginx, `trust proxy 1`), +/// sinon l'adresse de la connexion. +pub fn client_ip(headers: &HeaderMap, peer: Option) -> IpAddr { + if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + if let Some(ip) = xff.split(',').map(str::trim).filter(|s| !s.is_empty()).last() { + if let Ok(ip) = ip.parse() { + return ip; + } + } + } + peer.map(|p| p.ip()).unwrap_or(IpAddr::from([127, 0, 0, 1])) +} + +pub async fn rate_limit(State(state): State, request: Request, next: Next) -> Response { + let path = request.uri().path(); + if !path.starts_with("/api/") { + return next.run(request).await; + } + let peer = request.extensions().get::>().map(|c| c.0); + let ip = client_ip(request.headers(), peer); + + let decision = state.global_limiter.check(ip); + if !decision.allowed { + return too_many(&decision, "Trop de requêtes, réessayez plus tard"); + } + if path == "/api/auth/login" { + let auth = state.auth_limiter.check(ip); + if !auth.allowed { + return too_many(&auth, "Trop de tentatives de connexion, réessayez dans 15 minutes"); + } + } + + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert("ratelimit-limit", HeaderValue::from(decision.limit)); + headers.insert("ratelimit-remaining", HeaderValue::from(decision.remaining)); + headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs)); + response +} + +fn too_many(decision: &RateDecision, message: &str) -> Response { + let mut response = (StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": message }))).into_response(); + let headers = response.headers_mut(); + headers.insert("ratelimit-limit", HeaderValue::from(decision.limit)); + headers.insert("ratelimit-remaining", HeaderValue::from(0u32)); + headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs)); + headers.insert("retry-after", HeaderValue::from(decision.reset_secs)); + response +} + +const CSP: &str = "default-src 'self';\ +base-uri 'self';\ +font-src 'self' https://fonts.gstatic.com;\ +form-action 'self';\ +frame-ancestors 'self';\ +img-src 'self' data: https: blob:;\ +object-src 'none';\ +script-src 'self' 'unsafe-inline' 'unsafe-eval';\ +script-src-attr 'none';\ +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;\ +connect-src 'self' https://graph.facebook.com https://openrouter.ai https://res.cloudinary.com wss: ws:;\ +media-src 'self' https: data:;\ +upgrade-insecure-requests"; + +/// En-têtes posés par helmet dans l'ancien serveur. +pub async fn security_headers(request: Request, next: Next) -> Response { + let is_upload = request.uri().path().starts_with("/uploads/"); + let mut response = next.run(request).await; + let headers = response.headers_mut(); + let mut set = |name: &'static str, value: &'static str| { + headers + .entry(HeaderName::from_static(name)) + .or_insert(HeaderValue::from_static(value)); + }; + set("content-security-policy", CSP); + set("cross-origin-opener-policy", "same-origin"); + // Les fichiers téléversés sont lus par d'autres origines (rendu des Reels) + set("cross-origin-resource-policy", if is_upload { "cross-origin" } else { "same-origin" }); + set("origin-agent-cluster", "?1"); + set("referrer-policy", "no-referrer"); + set("strict-transport-security", "max-age=31536000; includeSubDomains"); + set("x-content-type-options", "nosniff"); + set("x-dns-prefetch-control", "off"); + set("x-download-options", "noopen"); + set("x-frame-options", "SAMEORIGIN"); + set("x-permitted-cross-domain-policies", "none"); + set("x-xss-protection", "0"); + response +} + +/// Journal des appels `/api` (méthode, chemin, statut, durée). +pub async fn log_requests(request: Request, next: Next) -> Response { + let path = request.uri().path().to_string(); + if !path.starts_with("/api") { + return next.run(request).await; + } + let method = request.method().clone(); + let started = Instant::now(); + let response = next.run(request).await; + let status = response.status(); + // Les 401 de /api/auth/session sont la vérification normale de session + if !(path == "/api/auth/session" && status == StatusCode::UNAUTHORIZED) { + tracing::info!("{method} {path} {} in {}ms", status.as_u16(), started.elapsed().as_millis()); + } + response +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..c551b82 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,102 @@ +#![allow(dead_code)] +//! SocialFlow — serveur HTTP en Rust. +//! +//! Remplace l'ancien serveur Node/Express : mêmes routes `/api`, même base +//! PostgreSQL (le schéma existant est repris tel quel puis complété au +//! démarrage), mêmes sessions (cookie `connect.sid` compatible) et même +//! chiffrement des jetons. Le frontend React compilé est servi depuis +//! `dist/public`. + +mod auth; +mod config; +mod crypto; +mod db; +mod error; +mod extract; +mod http; +mod migrate; +mod public_url; +mod routes; +mod services; +mod state; +mod time; +mod upload; + +use std::net::SocketAddr; + +use anyhow::Context; +use tracing_subscriber::EnvFilter; + +use crate::config::Config; +use crate::state::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=warn,hyper=warn")), + ) + .with_target(false) + .init(); + + let config = Config::from_env()?; + crypto::init(&config); + + let pool = db::connect(&config.database_url) + .await + .context("connexion à PostgreSQL impossible")?; + + migrate::run(&pool).await; + + let state = AppState::new(config, pool)?; + + auth::ensure_admin_user_exists(&state).await?; + services::audio::fix_audio_track_encoding(&state).await; + + let app = routes::router(state.clone()); + + let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("impossible d'écouter sur {addr}"))?; + tracing::info!("serving on port {}", state.config.port); + + // Tâches de fond : publications programmées, crons (jetons, statistiques, + // purge, suivi TikTok) et file des rendus de Reels. La file démarre une + // fois le serveur à l'écoute : les rendus lisent des fichiers servis ici. + services::scheduler::start(state.clone()); + services::cron::start(state.clone()); + services::reels::start(state.clone()); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + if let Ok(mut sig) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + sig.recv().await; + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("arrêt demandé, fermeture du serveur"); +} diff --git a/server/src/migrate.rs b/server/src/migrate.rs new file mode 100644 index 0000000..ac128b9 --- /dev/null +++ b/server/src/migrate.rs @@ -0,0 +1,301 @@ +//! Migration du schéma au démarrage, idempotente. +//! +//! Sur une base existante (créée par l'ancien serveur Node/Drizzle), toutes +//! les instructions sont sans effet ou complètent ce qui manque ; sur une base +//! vide, elles créent l'intégralité du schéma. Chaque instruction est exécutée +//! isolément : un échec est journalisé sans empêcher les suivantes, ni le +//! démarrage de l'application. + +use sqlx::PgPool; + +const ENUMS: &[&str] = &[ + r#"DO $$ BEGIN CREATE TYPE "platform" AS ENUM('facebook', 'instagram', 'tiktok'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"DO $$ BEGIN CREATE TYPE "post_type" AS ENUM('feed', 'story', 'both', 'reel'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"DO $$ BEGIN CREATE TYPE "post_status" AS ENUM('draft', 'scheduled', 'published', 'failed'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"DO $$ BEGIN CREATE TYPE "media_type" AS ENUM('image', 'video'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"DO $$ BEGIN CREATE TYPE "user_role" AS ENUM('admin', 'user'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"DO $$ BEGIN CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#, + r#"ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';"#, + r#"ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';"#, +]; + +const TABLES: &[&str] = &[ + r#"CREATE TABLE IF NOT EXISTS "users" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" text NOT NULL, + "password" text NOT NULL, + "role" "user_role" DEFAULT 'user' NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE ("username") + )"#, + r#"CREATE TABLE IF NOT EXISTS "social_pages" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "platform" "platform" NOT NULL, + "page_id" text NOT NULL, + "page_name" text NOT NULL, + "access_token" text NOT NULL, + "token_expires_at" timestamp, + "followers_count" integer DEFAULT 0, + "is_active" text DEFAULT 'true' NOT NULL, + "token_status" "token_status" DEFAULT 'valid', + "last_token_check" timestamp, + "user_access_token" text, + "user_token_expires_at" timestamp, + "token_error" text, + "refresh_token" text, + "refresh_token_expires_at" timestamp, + "scopes" text, + "avatar_url" text, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "media" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "type" "media_type" NOT NULL, + "cloudinary_public_id" text, + "original_url" text NOT NULL, + "facebook_landscape_url" text, + "facebook_feed_url" text, + "instagram_feed_url" text, + "instagram_story_url" text, + "thumbnail_url" text, + "file_name" text NOT NULL, + "file_size" integer NOT NULL, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "posts" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "content" text NOT NULL, + "ai_generated" text DEFAULT 'false' NOT NULL, + "product_info" jsonb, + "status" "post_status" DEFAULT 'draft' NOT NULL, + "scheduled_for" timestamp, + "generation_status" text, + "generation_progress" integer DEFAULT 0, + "generation_error" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "post_media" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade, + "media_id" varchar NOT NULL REFERENCES "media"("id") ON DELETE cascade, + "display_order" integer DEFAULT 0 NOT NULL + )"#, + r#"CREATE TABLE IF NOT EXISTS "scheduled_posts" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade, + "page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade, + "post_type" "post_type" NOT NULL, + "scheduled_at" timestamp NOT NULL, + "published_at" timestamp, + "external_post_id" text, + "error" text, + "publish_id" text, + "publish_status" text + )"#, + r#"CREATE TABLE IF NOT EXISTS "ai_generations" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "product_info" jsonb NOT NULL, + "generated_texts" jsonb NOT NULL, + "selected_text" text, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "cloudinary_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cloud_name" text NOT NULL, + "api_key" text NOT NULL, + "api_secret" text NOT NULL, + "logo_public_id" text, + "endpoint_url" text, + "public_url" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "openrouter_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "api_key" text NOT NULL, + "model" text DEFAULT 'anthropic/claude-sonnet-4.5' NOT NULL, + "system_prompt" text DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.' NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "openrouter_config_user_id_unique" UNIQUE ("user_id") + )"#, + r#"CREATE TABLE IF NOT EXISTS "user_page_permissions" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "post_analytics" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade, + "fetched_at" timestamp DEFAULT now(), + "impressions" integer DEFAULT 0, + "reach" integer DEFAULT 0, + "engagement" integer DEFAULT 0, + "reactions" integer DEFAULT 0, + "comments" integer DEFAULT 0, + "shares" integer DEFAULT 0, + "clicks" integer DEFAULT 0, + "raw_data" jsonb + )"#, + r#"CREATE TABLE IF NOT EXISTS "page_analytics_history" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade, + "date" timestamp NOT NULL, + "followers_count" integer DEFAULT 0, + "page_views" integer DEFAULT 0, + "page_reach" integer DEFAULT 0, + "page_engagement" integer DEFAULT 0, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "music_favorites" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "track_id" text NOT NULL, + "title" text NOT NULL, + "artist" text NOT NULL, + "album_name" text, + "duration" integer NOT NULL, + "preview_url" text NOT NULL, + "download_url" text NOT NULL, + "image_url" text, + "license" text, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "audio_tracks" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar REFERENCES "users"("id") ON DELETE cascade, + "title" text NOT NULL, + "file_name" text NOT NULL, + "url" text NOT NULL, + "duration" integer DEFAULT 0, + "created_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "app_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "external_api_key" text, + "gemini_api_key" text, + "qwen_tts_url" text, + "qwen_tts_api_key" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "tiktok_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_key" text NOT NULL, + "client_secret" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "facebook_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "app_id" text NOT NULL, + "app_secret" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + )"#, + r#"CREATE TABLE IF NOT EXISTS "reel_jobs" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "post_id" varchar REFERENCES "posts"("id") ON DELETE cascade, + "kind" text NOT NULL, + "params" jsonb NOT NULL, + "status" text NOT NULL DEFAULT 'pending', + "step" text, + "progress" integer NOT NULL DEFAULT 0, + "attempts" integer NOT NULL DEFAULT 0, + "result" jsonb, + "error" text, + "heartbeat_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() + )"#, + // Sessions : même table que connect-pg-simple, les utilisateurs déjà + // connectés le restent après la mise à jour. + r#"CREATE TABLE IF NOT EXISTS "session" ( + "sid" varchar NOT NULL COLLATE "default" PRIMARY KEY, + "sess" json NOT NULL, + "expire" timestamp(6) NOT NULL + )"#, +]; + +/// Colonnes ajoutées au fil des versions : complètent les bases anciennes. +const COLUMNS: &[&str] = &[ + r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid'"#, + r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "last_token_check" timestamp"#, + r#"ALTER TABLE "social_pages" + ADD COLUMN IF NOT EXISTS "refresh_token" text, + ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp, + ADD COLUMN IF NOT EXISTS "scopes" text, + ADD COLUMN IF NOT EXISTS "avatar_url" text"#, + r#"ALTER TABLE "social_pages" + ADD COLUMN IF NOT EXISTS "user_access_token" text, + ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp, + ADD COLUMN IF NOT EXISTS "token_error" text"#, + r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_status" text"#, + r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0"#, + r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_error" text"#, + r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "endpoint_url" text"#, + r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "public_url" text"#, + r#"ALTER TABLE "page_analytics_history" ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0"#, + r#"ALTER TABLE "app_config" ADD COLUMN IF NOT EXISTS "gemini_api_key" text"#, + r#"ALTER TABLE "app_config" + ADD COLUMN IF NOT EXISTS "qwen_tts_url" text, + ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text"#, + r#"ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "thumbnail_url" text"#, + r#"ALTER TABLE "scheduled_posts" + ADD COLUMN IF NOT EXISTS "publish_id" text, + ADD COLUMN IF NOT EXISTS "publish_status" text"#, +]; + +/// Index de performance (Postgres n'indexe pas les clés étrangères seul). +const INDEXES: &[(&str, &str)] = &[ + ("idx_posts_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")"#), + ("idx_media_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")"#), + ("idx_social_pages_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")"#), + ("idx_ai_generations_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")"#), + ("idx_scheduled_posts_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")"#), + ("idx_post_media_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")"#), + ("idx_post_media_media_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")"#), + ("idx_post_analytics_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")"#), + ("idx_page_analytics_history_page_date", r#"CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")"#), + ("idx_user_page_permissions_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")"#), + ("idx_user_page_permissions_page_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")"#), + ("idx_scheduled_posts_page_scheduled_at", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")"#), + ("idx_scheduled_posts_pending", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL"#), + ("idx_scheduled_posts_publish_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL"#), + ("idx_reel_jobs_queue", r#"CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')"#), + ("idx_posts_generation_status", r#"CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL"#), + ("IDX_session_expire", r#"CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire")"#), +]; + +pub async fn run(pool: &PgPool) { + tracing::info!("[Migration] Vérification du schéma…"); + let mut failures = 0; + + for statement in ENUMS.iter().chain(TABLES).chain(COLUMNS) { + if let Err(error) = sqlx::raw_sql(statement).execute(pool).await { + failures += 1; + let head: String = statement.split_whitespace().take(6).collect::>().join(" "); + tracing::warn!("[Migration] Échec de « {head} … » : {error}"); + } + } + + for (name, statement) in INDEXES { + if let Err(error) = sqlx::raw_sql(statement).execute(pool).await { + failures += 1; + tracing::warn!("[Migration] Index {name} non créé : {error}"); + } + } + + if failures == 0 { + tracing::info!("[Migration] Schéma à jour."); + } else { + tracing::warn!("[Migration] Terminée avec {failures} avertissement(s)."); + } +} diff --git a/server/src/public_url.rs b/server/src/public_url.rs new file mode 100644 index 0000000..a073959 --- /dev/null +++ b/server/src/public_url.rs @@ -0,0 +1,45 @@ +//! URL publique de l'application. +//! +//! Les flux OAuth (Facebook, TikTok) exigent une URI de redirection exacte. +//! `APP_URL` prime ; à défaut, l'URL est reconstruite depuis la requête +//! (en-têtes `X-Forwarded-Proto` / `X-Forwarded-Host` posés par Nginx). + +use axum::http::HeaderMap; + +use crate::state::AppState; + +const DEV_FALLBACK: &str = "http://localhost:5555"; + +fn first_header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// Base publique sans « / » final (« https://exemple.fr »). +pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) -> String { + if let Some(url) = &state.config.app_url { + return url.clone(); + } + if let Some(headers) = headers { + let host = first_header_value(headers, "x-forwarded-host").or_else(|| first_header_value(headers, "host")); + if let Some(host) = host { + let protocol = first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into()); + return format!("{protocol}://{host}"); + } + } + DEV_FALLBACK.to_string() +} + +/// La requête est-elle arrivée en HTTPS (directement ou derrière le proxy) ? +pub fn is_https(state: &AppState, headers: &HeaderMap) -> bool { + if let Some(url) = &state.config.app_url { + if url.starts_with("https://") { + return true; + } + } + first_header_value(headers, "x-forwarded-proto").as_deref() == Some("https") +} diff --git a/server/src/routes/analytics.rs b/server/src/routes/analytics.rs new file mode 100644 index 0000000..f189c4f --- /dev/null +++ b/server/src/routes/analytics.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/analytics.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/auth.rs b/server/src/routes/auth.rs new file mode 100644 index 0000000..d67b887 --- /dev/null +++ b/server/src/routes/auth.rs @@ -0,0 +1,86 @@ +//! Connexion, déconnexion, session courante. + +use axum::http::header::SET_COOKIE; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use crate::auth::{self, MaybeUser}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::Body; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/session", get(session)) + .route("/api/auth/default-password-status", get(default_password_status)) +} + +async fn login( + axum::extract::State(state): axum::extract::State, + headers: HeaderMap, + Body(body): Body, +) -> ApiResult { + const BAD_CREDENTIALS: &str = "Nom d'utilisateur ou mot de passe incorrect"; + + // passport-local : champs absents ou vides → « Missing credentials » + let username = body.get("username").and_then(Value::as_str).filter(|s| !s.is_empty()); + let password = body.get("password").and_then(Value::as_str).filter(|s| !s.is_empty()); + let (Some(username), Some(password)) = (username, password) else { + return Err(AppError::unauthorized("Missing credentials")); + }; + + let user = state + .db + .get_user_by_username(username) + .await + .or_500("Erreur lors de la connexion")? + .ok_or_else(|| AppError::unauthorized(BAD_CREDENTIALS))?; + + if !auth::verify_password(password, &user.password).await { + return Err(AppError::unauthorized(BAD_CREDENTIALS)); + } + + let previous = auth::load_session(&state, &headers).await; + let (_, cookie) = auth::start_session(&state, &headers, previous.as_ref(), &user.id) + .await + .or_500("Erreur lors de la connexion")?; + + Ok(auth::with_cookie(auth::user_json(&user), cookie)) +} + +async fn logout(axum::extract::State(state): axum::extract::State, headers: HeaderMap) -> ApiResult { + if let Some(session) = auth::load_session(&state, &headers).await { + auth::destroy_session(&state, &session.sid) + .await + .map_err(|_| AppError::internal("Erreur lors de la déconnexion"))?; + } + let mut response = Json(json!({ "message": "Déconnecté avec succès" })).into_response(); + response.headers_mut().append(SET_COOKIE, auth::clear_session_cookie()); + Ok(response) +} + +async fn session(MaybeUser(user): MaybeUser) -> Response { + match user { + Some((user, _)) => Json(auth::user_json(&user)).into_response(), + None => AppError::unauthorized("Non authentifié").into_response(), + } +} + +/// Le mot de passe de `admin` est-il toujours « admin » ? +async fn default_password_status(axum::extract::State(state): axum::extract::State) -> ApiResult> { + let admin = state + .db + .get_user_by_username("admin") + .await + .or_500("Erreur lors de la vérification du statut du mot de passe")?; + let is_default = match admin { + Some(admin) => auth::verify_password("admin", &admin.password).await, + None => false, + }; + Ok(Json(json!({ "isDefault": is_default }))) +} diff --git a/server/src/routes/core/mod.rs b/server/src/routes/core/mod.rs new file mode 100644 index 0000000..dc02efc --- /dev/null +++ b/server/src/routes/core/mod.rs @@ -0,0 +1,9 @@ +//! STUB — routes principales à porter depuis `server/routes.ts`. + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/external.rs b/server/src/routes/external.rs new file mode 100644 index 0000000..94c1729 --- /dev/null +++ b/server/src/routes/external.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/external.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/facebook.rs b/server/src/routes/facebook.rs new file mode 100644 index 0000000..f149911 --- /dev/null +++ b/server/src/routes/facebook.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/facebook.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/legal.rs b/server/src/routes/legal.rs new file mode 100644 index 0000000..84d5731 --- /dev/null +++ b/server/src/routes/legal.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/legal.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..ccfe00e --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,96 @@ +//! Assemblage des routes HTTP. + +mod auth; +mod core; +mod analytics; +mod external; +mod facebook; +mod legal; +mod reels; +mod tiktok; + +use axum::extract::{DefaultBodyLimit, Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware; +use axum::response::{IntoResponse, Response}; +use axum::Router; +use serde_json::json; +use tower::ServiceExt; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::extract::MAX_JSON_BODY; +use crate::http; +use crate::state::AppState; + +pub fn router(state: AppState) -> Router { + let api = Router::new() + .merge(auth::router()) + .merge(core::router()) + .nest("/api/analytics", analytics::router()) + .nest("/api/facebook", facebook::router()) + .nest("/api/tiktok", tiktok::router()) + .nest("/api/v1", external::router()) + .merge(reels::router()) + .merge(legal::router()); + + let config = &state.config; + for dir in [&config.uploads_dir, &config.audio_upload_dir, &config.temp_upload_dir] { + let _ = std::fs::create_dir_all(dir); + } + for sub in ["media", "logos", "stories"] { + let _ = std::fs::create_dir_all(config.uploads_dir.join(sub)); + } + + // Fichiers téléversés (médias, logos, stories, musique, fichiers temporaires des rendus) + let uploads = Router::new() + .nest_service("/uploads/audio", ServeDir::new(&config.audio_upload_dir)) + .nest_service("/uploads/temp", ServeDir::new(&config.temp_upload_dir)) + .nest_service("/uploads", ServeDir::new(&config.uploads_dir)); + + api.merge(uploads) + .fallback(fallback) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY)) + .layer(middleware::from_fn_with_state(state.clone(), http::rate_limit)) + .layer(middleware::from_fn(http::log_requests)) + .layer(middleware::from_fn(http::security_headers)) + .with_state(state) +} + +/// Route inconnue : JSON pour `/api`, sinon fichiers publics puis l'application React. +async fn fallback(State(state): State, headers: HeaderMap, request: Request) -> Response { + let path = request.uri().path().to_string(); + if path == "/api" || path.starts_with("/api/") { + return if crate::auth::current_user(&state, &headers).await.is_none() { + (StatusCode::UNAUTHORIZED, axum::Json(json!({ "error": "Non authentifié" }))).into_response() + } else { + (StatusCode::NOT_FOUND, axum::Json(json!({ "error": "Route introuvable" }))).into_response() + }; + } + + // Vérifications de domaine (TikTok, Google, Meta…), sans les fichiers cachés + let is_hidden = path.split('/').any(|part| part.starts_with('.')); + if !is_hidden && path != "/" { + let candidate = state.config.public_root_dir.join(path.trim_start_matches('/')); + if candidate.is_file() { + return serve(ServeFile::new(candidate), request).await; + } + } + + let index = state.config.static_dir.join("index.html"); + let spa = ServeDir::new(&state.config.static_dir) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(index)); + serve(spa, request).await +} + +async fn serve(service: S, request: Request) -> Response +where + S: tower::Service + Send, + S::Response: IntoResponse, + S::Future: Send, +{ + match service.oneshot(request).await { + Ok(response) => response.into_response(), + Err(never) => match never {}, + } +} diff --git a/server/src/routes/reels.rs b/server/src/routes/reels.rs new file mode 100644 index 0000000..27dafc2 --- /dev/null +++ b/server/src/routes/reels.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/reels.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/routes/tiktok.rs b/server/src/routes/tiktok.rs new file mode 100644 index 0000000..9da1d31 --- /dev/null +++ b/server/src/routes/tiktok.rs @@ -0,0 +1,9 @@ +//! STUB — routes à porter (voir `server/routes/tiktok.ts`). + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() +} diff --git a/server/src/services/analytics.rs b/server/src/services/analytics.rs new file mode 100644 index 0000000..af1e589 --- /dev/null +++ b/server/src/services/analytics.rs @@ -0,0 +1,11 @@ +//! Statistiques des posts et des pages (API Graph). +//! +//! STUB — à porter depuis `server/services/analytics.ts`. +#![allow(unused_variables)] + +use crate::state::AppState; + +/// Synchronise les statistiques de toutes les pages. +pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> { + Ok(()) +} diff --git a/server/src/services/audio.rs b/server/src/services/audio.rs new file mode 100644 index 0000000..7bb21c8 --- /dev/null +++ b/server/src/services/audio.rs @@ -0,0 +1,84 @@ +//! Bibliothèque audio : réparation des titres mal encodés et durée des pistes. + +use std::path::Path; + +use tokio::process::Command; + +use crate::state::AppState; + +/// Texte UTF-8 qui a été lu comme du latin1 (« é » au lieu de « é ») ? +fn looks_mis_decoded(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + chars + .windows(2) + .any(|w| ('\u{C0}'..='\u{C3}').contains(&w[0]) && ('\u{80}'..='\u{BF}').contains(&w[1])) +} + +/// Réencode chaque caractère en un octet latin1 puis relit en UTF-8 +/// (`Buffer.from(text, 'latin1').toString('utf8')`). +fn latin1_to_utf8(text: &str) -> String { + let bytes: Vec = text.chars().map(|c| (c as u32 & 0xFF) as u8).collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Corrige les titres corrompus des pistes audio ; renvoie le nombre corrigé. +pub async fn fix_audio_track_encoding(state: &AppState) -> usize { + let tracks = match state.db.get_audio_tracks().await { + Ok(tracks) => tracks, + Err(error) => { + tracing::error!("Error fixing audio track encoding: {error}"); + return 0; + } + }; + let mut fixed = 0; + for track in tracks { + if !looks_mis_decoded(&track.title) { + continue; + } + let title = latin1_to_utf8(&track.title); + let file_name = latin1_to_utf8(&track.file_name); + if sqlx::query("UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3") + .bind(&title) + .bind(&file_name) + .bind(&track.id) + .execute(&state.db.pool) + .await + .is_ok() + { + fixed += 1; + } + } + if fixed > 0 { + tracing::info!("🎵 Fixed encoding for {fixed} audio track(s)."); + } + fixed +} + +/// Durée d'un fichier audio en secondes (arrondie), via ffprobe ; 0 si inconnue. +pub async fn probe_duration_secs(path: &Path) -> i32 { + let output = Command::new("ffprobe") + .args(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1"]) + .arg(path) + .output() + .await; + match output { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .map(|d| d.round() as i32) + .unwrap_or(0), + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repairs_latin1_mojibake() { + assert!(looks_mis_decoded("Café")); + assert_eq!(latin1_to_utf8("Café"), "Café"); + assert!(!looks_mis_decoded("Café")); + } +} diff --git a/server/src/services/cron.rs b/server/src/services/cron.rs new file mode 100644 index 0000000..e891369 --- /dev/null +++ b/server/src/services/cron.rs @@ -0,0 +1,8 @@ +//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias). +//! +//! STUB — à porter depuis `server/cron.ts`. +#![allow(unused_variables)] + +use crate::state::AppState; + +pub fn start(state: AppState) {} diff --git a/server/src/services/facebook/mod.rs b/server/src/services/facebook/mod.rs new file mode 100644 index 0000000..a493604 --- /dev/null +++ b/server/src/services/facebook/mod.rs @@ -0,0 +1,40 @@ +//! Publication Facebook / Instagram (API Graph). +//! +//! STUB — à porter depuis `server/services/facebook.ts` (+ `imageProcessor.ts`, +//! `emojiRenderer.ts`, `facebook_oauth.ts`, `utils/graph_client.ts`). +#![allow(unused_variables)] + +use bytes::Bytes; + +use crate::db::{Media, Post, PostType, SocialPage}; +use crate::state::AppState; + +/// Publie un post (feed, story…) sur une page Facebook ; renvoie l'identifiant externe. +pub async fn publish_post( + state: &AppState, + post: &Post, + page: &SocialPage, + post_type: PostType, + media: &[Media], +) -> anyhow::Result { + anyhow::bail!("publication Facebook non implémentée") +} + +/// Publie une vidéo (Reel) envoyée en binaire ; renvoie l'identifiant de la vidéo. +pub async fn publish_video_from_buffer( + state: &AppState, + page: &SocialPage, + video: Bytes, + description: &str, +) -> anyhow::Result { + anyhow::bail!("publication de vidéo Facebook non implémentée") +} + +/// Publie une vidéo en story Facebook ; renvoie l'identifiant de la story. +pub async fn publish_video_story_from_buffer( + state: &AppState, + page: &SocialPage, + video: Bytes, +) -> anyhow::Result { + anyhow::bail!("story vidéo Facebook non implémentée") +} diff --git a/server/src/services/ffmpeg.rs b/server/src/services/ffmpeg.rs new file mode 100644 index 0000000..b771b70 --- /dev/null +++ b/server/src/services/ffmpeg.rs @@ -0,0 +1,44 @@ +//! Client du service FFmpeg (conteneur Python `ffmpeg-service`). +//! +//! STUB — à porter depuis `server/services/ffmpeg.ts`. +#![allow(unused_variables)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::state::AppState; + +/// Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QwenTarget { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +/// Catalogue des voix renvoyé par le service (noms de champs du service Python). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VoiceCatalog { + #[serde(default)] + pub qwen: Vec, + #[serde(default)] + pub qwen_available: bool, + /// Modèle et carte graphique du service Qwen, null s'il ne répond pas. + #[serde(default)] + pub qwen_status: Option, + #[serde(default)] + pub gemini: Vec, + #[serde(default)] + pub edge: Vec, +} + +/// Le service répond-il ? +pub async fn health_check(state: &AppState) -> bool { + false +} + +/// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service. +pub async fn list_voices(state: &AppState, qwen: Option) -> anyhow::Result { + anyhow::bail!("service FFmpeg non implémenté") +} diff --git a/server/src/services/media_purge.rs b/server/src/services/media_purge.rs new file mode 100644 index 0000000..71714ec --- /dev/null +++ b/server/src/services/media_purge.rs @@ -0,0 +1,10 @@ +//! Purge quotidienne des anciens médias. +//! +//! STUB — à porter depuis `server/services/media-purge.ts`. +#![allow(unused_variables)] + +use crate::state::AppState; + +pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> { + Ok(()) +} diff --git a/server/src/services/media_store.rs b/server/src/services/media_store.rs new file mode 100644 index 0000000..4bfb7f2 --- /dev/null +++ b/server/src/services/media_store.rs @@ -0,0 +1,180 @@ +//! Stockage local des fichiers (ex-`minio.ts`) : tout vit sous `uploads/` +//! et est servi en relatif (`/uploads/`), quel que soit le domaine. + +use std::path::{Path, PathBuf}; + +use bytes::Bytes; + +use crate::state::AppState; + +/// URL d'un fichier stocké : relative, pour fonctionner sur tout domaine. +pub fn build_url(object_key: &str) -> String { + format!("/uploads/{object_key}") +} + +/// URL absolue pour les services externes (API Facebook…) : préfixée par `APP_URL`. +pub fn resolve_public_url(state: &AppState, url: &str) -> String { + if url.is_empty() || !url.starts_with('/') { + return url.to_string(); + } + let base = state.config.app_url.as_deref().unwrap_or("http://localhost:5555"); + format!("{}{url}", base.trim_end_matches('/')) +} + +/// URL absolue pour les services Docker internes (FFmpeg…) : `INTERNAL_APP_URL`. +pub fn resolve_internal_url(state: &AppState, url: &str) -> String { + if url.is_empty() || !url.starts_with('/') { + return url.to_string(); + } + let base = state + .config + .internal_app_url + .as_deref() + .or(state.config.app_url.as_deref()) + .unwrap_or("http://localhost:5555"); + format!("{}{url}", base.trim_end_matches('/')) +} + +/// Chemin disque d'une URL `/uploads/...`, si elle reste dans le dossier des +/// uploads (refuse toute remontée `..`). +pub fn local_path(state: &AppState, url: &str) -> Option { + let rest = url.strip_prefix("/uploads/")?; + let rest = rest.split(['?', '#']).next().unwrap_or(rest); + let decoded = percent_encoding::percent_decode_str(rest).decode_utf8().ok()?; + if decoded.split('/').any(|part| part == ".." || part.is_empty()) { + return None; + } + let (root, relative) = if let Some(r) = decoded.strip_prefix("audio/") { + (state.config.audio_upload_dir.clone(), r.to_string()) + } else if let Some(r) = decoded.strip_prefix("temp/") { + (state.config.temp_upload_dir.clone(), r.to_string()) + } else { + (state.config.uploads_dir.clone(), decoded.to_string()) + }; + Some(root.join(relative)) +} + +/// Contenu d'un média, local (`/uploads/...`) ou distant. +pub async fn read_media_buffer(state: &AppState, url: &str) -> anyhow::Result { + if let Some(path) = local_path(state, url) { + if let Ok(data) = tokio::fs::read(&path).await { + return Ok(Bytes::from(data)); + } + } + let absolute = resolve_public_url(state, url); + let response = state.http.get(&absolute).send().await?; + if !response.status().is_success() { + anyhow::bail!( + "Impossible de récupérer le média depuis {absolute} ({})", + response.status().as_u16() + ); + } + Ok(response.bytes().await?) +} + +fn safe_name(file_name: &str) -> String { + let base = Path::new(file_name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + base.chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' }) + .collect() +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +async fn write(state: &AppState, object_key: &str, data: &[u8]) -> anyhow::Result<()> { + let path = state.config.uploads_dir.join(object_key); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&path, data).await?; + Ok(()) +} + +/// Résultat d'un enregistrement de média : toutes les déclinaisons pointent sur le même fichier. +#[derive(Debug, Clone)] +pub struct StoredMedia { + pub public_id: String, + pub original_url: String, + pub facebook_feed_url: Option, + pub instagram_feed_url: Option, + pub instagram_story_url: Option, +} + +/// Source d'un média à ranger : fichier sur disque ou contenu en mémoire. +pub enum MediaSource<'a> { + Path(&'a Path), + Bytes(&'a [u8]), +} + +pub async fn upload_media(state: &AppState, source: MediaSource<'_>, file_name: &str) -> anyhow::Result { + let object_key = format!("media/{}-{}", now_ms(), safe_name(file_name)); + let target = state.config.uploads_dir.join(&object_key); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + match source { + MediaSource::Path(path) => { + // Copie plutôt que renommage : le fichier temporaire peut être sur un autre volume + tokio::fs::copy(path, &target).await?; + } + MediaSource::Bytes(data) => tokio::fs::write(&target, data).await?, + } + let url = build_url(&object_key); + Ok(StoredMedia { + public_id: object_key, + original_url: url.clone(), + facebook_feed_url: Some(url.clone()), + instagram_feed_url: Some(url.clone()), + instagram_story_url: Some(url), + }) +} + +/// Supprime un fichier rangé par `upload_media` (clé relative à `uploads/`). +pub async fn delete_media(state: &AppState, public_id: &str) -> anyhow::Result<()> { + if public_id.split('/').any(|p| p == "..") { + anyhow::bail!("clé de média invalide : {public_id}"); + } + let path = state.config.uploads_dir.join(public_id); + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Image de story avec texte incrusté ; renvoie son URL. +pub async fn upload_story_image(state: &AppState, data: &[u8], original_file_name: &str) -> anyhow::Result { + let object_key = format!("stories/story-{}-{}", now_ms(), safe_name(original_file_name)); + write(state, &object_key, data).await?; + Ok(build_url(&object_key)) +} + +/// Vignette vidéo (conservée après la suppression de la vidéo source). +pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> { + let object_key = format!("thumbnails/{}", safe_name(file_name)); + write(state, &object_key, data).await?; + let url = build_url(&object_key); + Ok((object_key, url)) +} + +/// Logo de l'entreprise ; renvoie (clé, URL). +pub async fn upload_logo(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> { + let ext = Path::new(file_name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!(".{e}")) + .unwrap_or_else(|| ".png".into()); + let object_key = format!("logos/logo-{}{ext}", now_ms()); + write(state, &object_key, data).await?; + let url = build_url(&object_key); + Ok((object_key, url)) +} + +pub async fn delete_logo(state: &AppState, public_id: &str) -> anyhow::Result<()> { + delete_media(state, public_id).await +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs new file mode 100644 index 0000000..690442b --- /dev/null +++ b/server/src/services/mod.rs @@ -0,0 +1,15 @@ +//! Services métier (intégrations externes, traitements de fond). + +pub mod analytics; +pub mod audio; +pub mod cron; +pub mod facebook; +pub mod ffmpeg; +pub mod media_purge; +pub mod media_store; +pub mod openrouter; +pub mod reels; +pub mod scheduler; +pub mod thumbnail; +pub mod tiktok; +pub mod token_manager; diff --git a/server/src/services/openrouter.rs b/server/src/services/openrouter.rs new file mode 100644 index 0000000..34aa97b --- /dev/null +++ b/server/src/services/openrouter.rs @@ -0,0 +1,24 @@ +//! Génération de textes par IA (OpenRouter). +//! +//! STUB — à porter depuis `server/services/openrouter.ts`. +#![allow(unused_variables)] + +use axum::http::StatusCode; +use serde_json::Value; + +use crate::state::AppState; + +/// Génère les variantes de texte (`{ variant, text, characterCount }`). +pub async fn generate_post_text( + state: &AppState, + product_info: &Value, + user_id: &str, + model_override: Option<&str>, +) -> anyhow::Result> { + anyhow::bail!("Configuration OpenRouter non trouvée") +} + +/// Statut HTTP et message à renvoyer pour une erreur de génération. +pub fn describe_generation_error(error: &anyhow::Error) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) +} diff --git a/server/src/services/reels/mod.rs b/server/src/services/reels/mod.rs new file mode 100644 index 0000000..cda7019 --- /dev/null +++ b/server/src/services/reels/mod.rs @@ -0,0 +1,24 @@ +//! Reels : file de rendus persistante, montage, publication. +//! +//! STUB — à porter depuis `server/services/reels/*`. +#![allow(unused_variables)] + +use serde_json::Value; + +use crate::state::AppState; + +/// Démarre le worker de la file `reel_jobs`. +pub fn start(state: AppState) {} + +/// Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; None si invalide. +pub fn normalize_qwen_url(value: Option<&Value>) -> Option { + let text = value?.as_str()?.trim(); + if text.is_empty() { + return None; + } + let url = url::Url::parse(text).ok()?; + if url.scheme() != "http" && url.scheme() != "https" { + return None; + } + Some(url.to_string().trim_end_matches('/').to_string()) +} diff --git a/server/src/services/scheduler.rs b/server/src/services/scheduler.rs new file mode 100644 index 0000000..4b5829e --- /dev/null +++ b/server/src/services/scheduler.rs @@ -0,0 +1,8 @@ +//! Publication des posts programmés (toutes les minutes). +//! +//! STUB — à porter depuis `server/services/scheduler.ts`. +#![allow(unused_variables)] + +use crate::state::AppState; + +pub fn start(state: AppState) {} diff --git a/server/src/services/thumbnail.rs b/server/src/services/thumbnail.rs new file mode 100644 index 0000000..e6ab732 --- /dev/null +++ b/server/src/services/thumbnail.rs @@ -0,0 +1,93 @@ +//! Vignettes vidéo, extraites avec FFmpeg au moment de la création du média : +//! elles survivent à la suppression de la vidéo (historique illustré). + +use std::path::{Path, PathBuf}; + +use rand::Rng; +use tokio::process::Command; + +use crate::services::media_store; +use crate::state::AppState; + +/// Source de la vidéo : fichier sur disque ou contenu en mémoire. +pub enum ThumbnailSource<'a> { + Path(&'a Path), + Bytes(&'a [u8]), +} + +/// Extrait une image à `seek_time` secondes ; vrai si l'image a été écrite. +pub async fn generate_video_thumbnail(video: &Path, output: &Path, seek_time: f64) -> bool { + // Arguments séparés : aucun passage par le shell + let status = Command::new("ffmpeg") + .args(["-y", "-ss", &seek_time.to_string(), "-i"]) + .arg(video) + .args(["-vframes", "1", "-q:v", "2"]) + .arg(output) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + match status { + Ok(s) if s.success() => tokio::fs::metadata(output).await.is_ok(), + Ok(_) => false, + Err(error) => { + tracing::warn!("⚠️ Failed to generate video thumbnail: {error}"); + false + } + } +} + +/// Produit la vignette et la range dans le stockage public. Tolérante à +/// l'échec : renvoie `None` sans jamais faire échouer l'appelant. +pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_>) -> Option { + let temp_dir = state.config.uploads_dir.join("temp"); + if tokio::fs::create_dir_all(&temp_dir).await.is_err() { + return None; + } + let stamp = format!( + "{}-{}", + chrono::Utc::now().timestamp_millis(), + rand::thread_rng().gen_range(0..1_000_000_000u32) + ); + let output = temp_dir.join(format!("thumb-{stamp}.jpg")); + + let mut temp_video: Option = None; + let video_path: PathBuf = match source { + ThumbnailSource::Path(path) => path.to_path_buf(), + ThumbnailSource::Bytes(data) => { + let path = temp_dir.join(format!("thumb-src-{stamp}.mp4")); + if tokio::fs::write(&path, data).await.is_err() { + return None; + } + temp_video = Some(path.clone()); + path + } + }; + + // Une vidéo de moins d'une seconde n'a pas d'image à 1 s : repli sur la première + let mut ok = generate_video_thumbnail(&video_path, &output, 1.0).await; + if !ok { + ok = generate_video_thumbnail(&video_path, &output, 0.0).await; + } + + let result = if ok { + match tokio::fs::read(&output).await { + Ok(data) => match media_store::upload_thumbnail(state, &data, &format!("thumb-{stamp}.jpg")).await { + Ok((_, url)) => Some(url), + Err(error) => { + tracing::warn!("⚠️ Vignette non enregistrée : {error}"); + None + } + }, + Err(_) => None, + } + } else { + None + }; + + let _ = tokio::fs::remove_file(&output).await; + if let Some(path) = temp_video { + let _ = tokio::fs::remove_file(path).await; + } + result +} diff --git a/server/src/services/tiktok/mod.rs b/server/src/services/tiktok/mod.rs new file mode 100644 index 0000000..a395933 --- /dev/null +++ b/server/src/services/tiktok/mod.rs @@ -0,0 +1,24 @@ +//! Comptes TikTok : OAuth, renouvellement des jetons, publication de vidéos. +//! +//! STUB — à porter depuis `server/services/tiktok.ts`. +#![allow(unused_variables)] + +use bytes::Bytes; + +use crate::db::SocialPage; +use crate::state::AppState; + +/// Envoie une vidéo à TikTok ; renvoie le `publish_id` (publication asynchrone). +pub async fn publish_video_from_buffer( + state: &AppState, + page: &SocialPage, + video: Bytes, + description: &str, +) -> anyhow::Result { + anyhow::bail!("publication TikTok non implémentée") +} + +/// Interroge le statut des envois TikTok en attente et met à jour les publications. +pub async fn sync_pending_publications(state: &AppState) -> anyhow::Result<()> { + Ok(()) +} diff --git a/server/src/services/token_manager.rs b/server/src/services/token_manager.rs new file mode 100644 index 0000000..e39f6c3 --- /dev/null +++ b/server/src/services/token_manager.rs @@ -0,0 +1,26 @@ +//! Contrôle et renouvellement des jetons de page. +//! +//! STUB — à porter depuis `server/services/token_manager.ts`. +#![allow(unused_variables)] + +use serde::Serialize; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenCheckResult { + pub page_id: String, + pub page_name: String, + /// 'valid' | 'expiring' | 'expired' | 'error' + pub status: String, + /// Vrai quand ce passage a effectivement réécrit un nouveau jeton. + pub renewed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Contrôle toutes les pages et renouvelle les jetons qui doivent l'être. +pub async fn check_and_refresh_tokens(state: &AppState) -> Vec { + Vec::new() +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..aafd0f3 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,72 @@ +//! État partagé par les routes et les tâches de fond. + +use std::ops::Deref; +use std::sync::Arc; +use std::time::Duration; + +use sqlx::PgPool; +use tokio::sync::{Notify, RwLock}; + +use crate::config::Config; +use crate::db::Db; +use crate::http::RateLimiter; + +/// Adresse et clé du service FFmpeg ; modifiables depuis les paramètres. +#[derive(Debug, Clone, Default)] +pub struct FfmpegSettings { + pub api_url: Option, + pub api_key: Option, +} + +pub struct Inner { + pub config: Config, + pub db: Db, + /// Client HTTP commun (APIs Meta, TikTok, OpenRouter, services internes). + pub http: reqwest::Client, + pub ffmpeg: RwLock, + /// Réveille la file des rendus de Reels quand un job est ajouté. + pub reel_queue: Notify, + pub global_limiter: RateLimiter, + pub auth_limiter: RateLimiter, +} + +#[derive(Clone)] +pub struct AppState(Arc); + +impl Deref for AppState { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AppState { + pub fn new(config: Config, pool: PgPool) -> anyhow::Result { + let http = reqwest::Client::builder() + .user_agent("SocialFlow/2.0") + .connect_timeout(Duration::from_secs(30)) + .build()?; + + let ffmpeg = FfmpegSettings { + api_url: config.ffmpeg_api_url.as_ref().map(|u| u.trim_end_matches('/').to_string()), + api_key: config.ffmpeg_api_key.clone(), + }; + if ffmpeg.api_url.is_none() || ffmpeg.api_key.is_none() { + tracing::warn!("⚠️ FFmpeg API not configured - video processing will be skipped"); + } else if let Some(url) = &ffmpeg.api_url { + tracing::info!("🎬 FFmpeg Service configured: {url}"); + } + + Ok(Self(Arc::new(Inner { + db: Db::new(pool), + http, + ffmpeg: RwLock::new(ffmpeg), + reel_queue: Notify::new(), + // 1000 requêtes / 15 min par IP sur /api, 30 tentatives de connexion + global_limiter: RateLimiter::new(1000, Duration::from_secs(15 * 60)), + auth_limiter: RateLimiter::new(30, Duration::from_secs(15 * 60)), + config, + }))) + } +} diff --git a/server/src/time.rs b/server/src/time.rs new file mode 100644 index 0000000..bbb67c6 --- /dev/null +++ b/server/src/time.rs @@ -0,0 +1,156 @@ +//! Horodatages. +//! +//! Les colonnes sont des `timestamp` sans fuseau qui contiennent de l'UTC +//! (l'ancien serveur tournait en UTC dans Docker). On les expose au navigateur +//! comme le faisait `JSON.stringify(new Date())` : `2024-05-01T08:30:00.000Z`. + +use std::fmt; + +use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)] +#[sqlx(transparent)] +pub struct Ts(pub NaiveDateTime); + +impl Ts { + pub fn now() -> Self { + // Précision milliseconde, comme une Date JavaScript + let now = Utc::now().naive_utc(); + Ts(now - Duration::nanoseconds(now.and_utc().timestamp_subsec_nanos() as i64 % 1_000_000)) + } + + pub fn from_utc(dt: DateTime) -> Self { + Ts(dt.naive_utc()) + } + + pub fn utc(&self) -> DateTime { + self.0.and_utc() + } + + pub fn to_iso(&self) -> String { + self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + } + + pub fn timestamp_millis(&self) -> i64 { + self.0.and_utc().timestamp_millis() + } + + pub fn from_millis(ms: i64) -> Option { + DateTime::::from_timestamp_millis(ms).map(Ts::from_utc) + } + + pub fn add(&self, d: Duration) -> Self { + Ts(self.0 + d) + } + + /// Équivalent de `new Date(value)` pour les formats envoyés par le client : + /// ISO 8601 avec ou sans fuseau (sans fuseau = UTC, l'heure du conteneur), + /// date seule, ou nombre de millisecondes. + pub fn parse(value: &str) -> Option { + parse_js_date(value) + } +} + +impl fmt::Display for Ts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_iso()) + } +} + +impl Serialize for Ts { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_iso()) + } +} + +impl<'de> Deserialize<'de> for Ts { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match &value { + serde_json::Value::String(s) => { + parse_js_date(s).ok_or_else(|| serde::de::Error::custom(format!("date invalide : {s}"))) + } + serde_json::Value::Number(n) => n + .as_i64() + .or_else(|| n.as_f64().map(|f| f as i64)) + .and_then(Ts::from_millis) + .ok_or_else(|| serde::de::Error::custom("date invalide")), + _ => Err(serde::de::Error::custom("date invalide")), + } + } +} + +impl From for Ts { + fn from(value: NaiveDateTime) -> Self { + Ts(value) + } +} + +impl From> for Ts { + fn from(value: DateTime) -> Self { + Ts::from_utc(value) + } +} + +pub fn parse_js_date(value: &str) -> Option { + let s = value.trim(); + if s.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(Ts::from_utc(dt.with_timezone(&Utc))); + } + // Variantes ISO tolérées par Date.parse : décalage sans « : », espace au lieu de « T » + for fmt in ["%Y-%m-%dT%H:%M:%S%.f%z", "%Y-%m-%d %H:%M:%S%.f%z", "%Y-%m-%dT%H:%M%z"] { + if let Ok(dt) = DateTime::parse_from_str(s, fmt) { + return Some(Ts::from_utc(dt.with_timezone(&Utc))); + } + } + for fmt in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { + return Some(Ts(dt)); + } + } + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + return d.and_hms_opt(0, 0, 0).map(Ts); + } + if let Ok(dt) = DateTime::parse_from_rfc2822(s) { + return Some(Ts::from_utc(dt.with_timezone(&Utc))); + } + if let Ok(ms) = s.parse::() { + return Ts::from_millis(ms); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_round_trip() { + let ts = parse_js_date("2024-05-01T08:30:00.123Z").unwrap(); + assert_eq!(ts.to_iso(), "2024-05-01T08:30:00.123Z"); + } + + #[test] + fn offsets_are_converted_to_utc() { + let ts = parse_js_date("2024-05-01T10:30:00+02:00").unwrap(); + assert_eq!(ts.to_iso(), "2024-05-01T08:30:00.000Z"); + } + + #[test] + fn naive_and_date_only() { + assert_eq!(parse_js_date("2024-05-01T08:30").unwrap().to_iso(), "2024-05-01T08:30:00.000Z"); + assert_eq!(parse_js_date("2024-05-01").unwrap().to_iso(), "2024-05-01T00:00:00.000Z"); + assert!(parse_js_date("pas une date").is_none()); + } +} diff --git a/server/src/upload.rs b/server/src/upload.rs new file mode 100644 index 0000000..dd86dc3 --- /dev/null +++ b/server/src/upload.rs @@ -0,0 +1,136 @@ +//! Réception de fichiers `multipart/form-data` (équivalent de multer avec +//! stockage disque) : chaque fichier est écrit en flux sur le disque. + +use std::path::{Path, PathBuf}; + +use axum::extract::Multipart; +use rand::Rng; +use tokio::io::AsyncWriteExt; + +use crate::error::AppError; + +#[derive(Debug, Clone)] +pub struct UploadedFile { + pub field: String, + /// Nom d'origine envoyé par le navigateur. + pub original_name: String, + /// Nom sur le disque (`--`). + pub file_name: String, + pub path: PathBuf, + pub size: u64, + pub mime: String, +} + +pub struct UploadRules<'a> { + /// Champs de fichier acceptés (`file`, `logo`, `files`…). + pub fields: &'a [&'a str], + pub dir: &'a Path, + pub max_file_size: u64, + pub max_files: usize, + /// Filtre (type MIME, nom d'origine) → message d'erreur si refusé. + pub filter: &'a (dyn Fn(&str, &str) -> Result<(), String> + Send + Sync), +} + +/// Nom de fichier multer : caractères hors `[a-zA-Z0-9.]` remplacés par `_`. +pub fn disk_name(original: &str) -> String { + let safe: String = original + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }) + .collect(); + let millis = chrono::Utc::now().timestamp_millis(); + let random: u32 = rand::thread_rng().gen_range(0..1_000_000_000); + format!("{millis}-{random}-{safe}") +} + +/// Lit le formulaire ; les champs texte sont renvoyés à part. +pub async fn receive_files( + mut multipart: Multipart, + rules: UploadRules<'_>, +) -> Result<(Vec, Vec<(String, String)>), AppError> { + tokio::fs::create_dir_all(rules.dir) + .await + .map_err(|e| AppError::internal_logged("Dossier d'upload inaccessible", e))?; + + let mut files: Vec = Vec::new(); + let mut fields: Vec<(String, String)> = Vec::new(); + + let result: Result<(), AppError> = async { + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| AppError::bad_request(format!("Formulaire invalide : {e}")))? + { + let name = field.name().unwrap_or("").to_string(); + let Some(original_name) = field.file_name().map(str::to_string) else { + let text = field.text().await.unwrap_or_default(); + fields.push((name, text)); + continue; + }; + if !rules.fields.contains(&name.as_str()) { + return Err(AppError::internal(format!("Unexpected field: {name}"))); + } + if files.len() >= rules.max_files { + return Err(AppError::internal("Too many files")); + } + let mime = field + .content_type() + .map(str::to_string) + .unwrap_or_else(|| "application/octet-stream".into()); + if let Err(message) = (rules.filter)(&mime, &original_name) { + return Err(AppError::internal(message)); + } + + let file_name = disk_name(&original_name); + let path = rules.dir.join(&file_name); + let mut out = tokio::fs::File::create(&path) + .await + .map_err(|e| AppError::internal_logged("Écriture du fichier impossible", e))?; + let mut size: u64 = 0; + let mut uploaded = UploadedFile { + field: name, + original_name, + file_name, + path: path.clone(), + size: 0, + mime, + }; + // Ajouté tout de suite : nettoyé en cas d'erreur plus bas + files.push(uploaded.clone()); + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| AppError::bad_request(format!("Réception interrompue : {e}")))? + { + size += chunk.len() as u64; + if size > rules.max_file_size { + return Err(AppError::new(axum::http::StatusCode::PAYLOAD_TOO_LARGE, "File too large")); + } + out.write_all(&chunk) + .await + .map_err(|e| AppError::internal_logged("Écriture du fichier impossible", e))?; + } + out.flush().await.ok(); + uploaded.size = size; + if let Some(last) = files.last_mut() { + *last = uploaded; + } + } + Ok(()) + } + .await; + + if let Err(error) = result { + for file in &files { + let _ = tokio::fs::remove_file(&file.path).await; + } + return Err(error); + } + Ok((files, fields)) +} + +/// Supprime des fichiers temporaires sans se soucier des erreurs. +pub async fn cleanup(files: &[UploadedFile]) { + for file in files { + let _ = tokio::fs::remove_file(&file.path).await; + } +}