From f9828d4887c4fb71651e108bd006dad8b1c86f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:14:58 +0000 Subject: [PATCH 01/10] 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; + } +} From 25703abb7d7e05bde0f69b5df9ab0c62f64eaf45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:20:38 +0000 Subject: [PATCH 02/10] Image Docker, CI et docs pour le serveur Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image en trois étapes (frontend Vite, binaire Rust, Debian slim avec FFmpeg), healthcheck /healthz, dépendances npm propres à l'ancien serveur retirées, CI Rust (fmt, clippy, tests) et documentation de la mise à jour au prochain démarrage de la stack. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- .dockerignore | 5 + .github/workflows/ci.yml | 24 +- .gitignore | 1 + DOCKER.md | 44 +- Dockerfile | 136 +- README.md | 79 +- docker-compose.yml | 1 - package-lock.json | 8179 +------------------------------------- package.json | 52 +- replit.md | 2 +- server/src/routes/mod.rs | 9 + tsconfig.json | 2 +- vite.config.ts | 6 + vitest.config.ts | 2 +- 14 files changed, 272 insertions(+), 8270 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8c6cc6c..14de69c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,3 +38,8 @@ logs # Uploads (seront montés comme volume) uploads/* + +# Compilation Rust +server/target +**/target +.claude diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ad92c6..0c19d7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ on: pull_request: jobs: - app: - name: Application (TypeScript) + frontend: + name: Frontend (TypeScript) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -15,13 +15,29 @@ jobs: with: node-version: 20 cache: npm - # Les dépendances natives (canvas, sharp) et le navigateur Remotion ne - # servent pas aux vérifications : on saute leurs scripts d'installation. - run: npm ci --ignore-scripts - run: npm run check - run: npm test - run: npm run build + server: + name: Serveur (Rust) + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + - run: cargo test --locked + ffmpeg-service: name: Service vidéo (Python) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 4fa766c..4c4b024 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ npm-debug.log* Thumbs.db # Rust server/target +.claude/worktrees diff --git a/DOCKER.md b/DOCKER.md index 9c52bb6..03bb90d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -4,7 +4,8 @@ L'application utilise Docker Compose pour orchestrer : - **PostgreSQL 16** : Base de données sur réseau interne -- **Social Flow App** : Application Node.js exposée sur le port **4523** +- **Social Flow App** : serveur Rust (API + frontend compilé), port interne **5555** +- **ffmpeg-api** / **remotion-renderer** : services de traitement vidéo des Reels ## 🚀 Démarrage rapide @@ -203,7 +204,8 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section ## 📊 Healthchecks - **PostgreSQL** : Vérifie que la base est prête avant de démarrer l'app -- **Migrations** : Exécutées automatiquement au démarrage (`drizzle-kit push --force`) +- **Migrations** : exécutées automatiquement par le serveur à chaque démarrage (idempotentes) +- **Application** : `GET /healthz` répond `ok` quand le serveur et la base sont joignables (healthcheck de l'image) ## 🐛 Dépannage @@ -232,23 +234,37 @@ Vérifiez que `DATABASE_URL` dans `.env` correspond aux variables `PGUSER`, `PGP ## 📦 Volumes Docker -- `postgres_data` : Données persistantes de PostgreSQL -- Mappages locaux : - - `./attached_assets` → `/app/attached_assets` (médias uploadés) - - `./migrations` → `/app/migrations` (migrations DB) +- `postgres_data` : données persistantes de PostgreSQL +- `encryption_key` : clé de chiffrement des jetons (`/app/.encryption-key-data`) +- `audio_uploads`, `media_uploads`, `logos_uploads`, `stories_uploads` : fichiers téléversés (`/app/uploads/...`) ## 🔄 Mise à jour de l'application +L'image `socialflow-app` est construite à partir des sources +(`pull_policy: build`) : le prochain démarrage de la stack après un `git pull` +applique la nouvelle version. + ```bash -# 1. Pull les dernières modifications +# 1. Récupérer les dernières modifications git pull -# 2. Reconstruire l'image -docker-compose build +# 2. Reconstruire et redémarrer (Portainer : « Update the stack ») +docker compose up -d --build -# 3. Redémarrer -docker-compose up -d - -# 4. Vérifier les logs -docker-compose logs -f app +# 3. Vérifier les logs +docker compose logs -f app ``` + +Au démarrage, le serveur complète le schéma de la base (tables, colonnes, +index) : aucune commande de migration n'est nécessaire. Les données, les +fichiers téléversés, la clé de chiffrement et les sessions ouvertes sont +conservés. + +### Passage au serveur Rust + +Depuis la version 2, le serveur est écrit en Rust (dossier `server/`) et +remplace l'ancien serveur Node/Express. La bascule ne demande rien de plus +qu'une reconstruction : mêmes variables d'environnement, même base, mêmes +volumes, et les utilisateurs déjà connectés le restent (cookie `connect.sid` +et table `session` repris tels quels). Le rendu Remotion des Reels tourne +désormais dans son propre conteneur (`remotion-renderer`). diff --git a/Dockerfile b/Dockerfile index cb9a2cc..75b7ffe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,84 +1,68 @@ -# Debian slim — glibc requis par le Chrome headless shell de Remotion (posix_fallocate64, etc.) -FROM node:20-slim +# SocialFlow — image de l'application : serveur Rust + frontend React compilé. +# +# Trois étapes : le frontend est compilé par Vite, le serveur par Cargo, et +# l'image finale ne contient que le binaire, les fichiers statiques et FFmpeg +# (vignettes vidéo, durée des pistes audio). Pas de Node à l'exécution. + +# --------------------------------------------------------------------------- +# 1. Frontend (Vite) +# --------------------------------------------------------------------------- +FROM node:20-slim AS frontend +WORKDIR /app + +COPY package.json package-lock.json ./ +# Aucune dépendance native n'est utile au build du frontend +RUN npm ci --ignore-scripts --no-audit --no-fund + +COPY client ./client +COPY shared ./shared +COPY attached_assets ./attached_assets +COPY vite.config.ts tsconfig.json tailwind.config.ts postcss.config.js components.json ./ +RUN npx vite build + +# --------------------------------------------------------------------------- +# 2. Serveur (Rust) +# --------------------------------------------------------------------------- +FROM rust:1-slim-bookworm AS backend +WORKDIR /app/server + +# Dépendances compilées dans une couche à part : elles ne sont recompilées que +# si Cargo.toml / Cargo.lock changent. +COPY server/Cargo.toml server/Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs \ + && cargo build --release --locked \ + && rm -rf src target/release/socialflow target/release/deps/socialflow-* target/release/.fingerprint/socialflow-* + +COPY server/src ./src +RUN cargo build --release --locked + +# --------------------------------------------------------------------------- +# 3. Image d'exécution +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + ffmpeg \ + fonts-dejavu-core \ + fontconfig \ + curl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app -# Augmenter la mémoire AVANT npm install + build (évite OOM sur les grosses dépendances) -ENV NODE_OPTIONS="--max-old-space-size=4096" +COPY --from=backend /app/server/target/release/socialflow /usr/local/bin/socialflow +COPY --from=frontend /app/dist/public ./dist/public +COPY public-root ./public-root +COPY server/fonts ./server/fonts -# Dépendances système : build tools, canvas/sharp, fonts, ffmpeg + dépendances Chrome -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 make g++ \ - libcairo2-dev libjpeg-dev libpango1.0-dev libgif-dev libpixman-1-dev \ - libvips-dev pkg-config \ - fonts-dejavu-core fontconfig \ - curl tar bzip2 \ - ffmpeg \ - libcap2-bin \ - ca-certificates \ - # Dépendances Chrome headless - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libgbm1 \ - libglib2.0-0 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libpango-1.0-0 \ - libx11-6 \ - libx11-xcb1 \ - libxcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxkbcommon0 \ - libxrandr2 \ - libxshmfence1 \ - libxtst6 \ - && rm -rf /var/lib/apt/lists/* - -# Copier les fichiers de configuration -COPY package*.json ./ - -# Installer les dépendances -RUN npm install --legacy-peer-deps --no-audit --no-fund - -# Copier tout le code source -COPY . . - -# Télécharger et installer la police DejaVu Sans -RUN mkdir -p server/fonts && \ - cd server/fonts && \ - curl -L -f --retry 3 --retry-delay 2 -o dejavu.tar.bz2 "https://sourceforge.net/projects/dejavu/files/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2/download" && \ - tar -xjf dejavu.tar.bz2 && \ - cp dejavu-fonts-ttf-2.37/ttf/DejaVuSans.ttf . && \ - rm -rf dejavu.tar.bz2 dejavu-fonts-ttf-2.37 && \ - echo "✓ Police DejaVu Sans installée" - -# Build du frontend et backend -RUN npm run build - -# Nettoyer les fichiers inutiles -RUN rm -rf client/node_modules \ - client/.vite \ - node_modules/.cache \ - /tmp/remotion-* - -# Pré-bundler la composition Remotion -RUN node scripts/prebundle-remotion.mjs || true - -# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian) -RUN npx remotion browser ensure && echo "✓ Remotion browser prêt" - -# Exposer le port -EXPOSE 5555 +RUN mkdir -p uploads/media uploads/logos uploads/stories uploads/audio uploads/temp ENV NODE_ENV=production ENV PORT=5555 +EXPOSE 5555 -CMD ["npm", "run", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://localhost:${PORT}/healthz" > /dev/null || exit 1 + +CMD ["socialflow"] diff --git a/README.md b/README.md index 19904e1..fc421c6 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ Architecture Production-Ready avec sécurité renforcée. | Fonctionnalité | Détail | | ------------------------------- | ---------------------------------------------------- | -| **Authentification** | Passport.js + bcrypt, sessions PostgreSQL | -| **Rate Limiting** | 100 req/15min global, 5 tentatives login/15min | -| **Headers de sécurité** | Helmet (CSP, HSTS, XSS protection) | +| **Authentification** | bcrypt, sessions PostgreSQL (cookie signé) | +| **Rate Limiting** | 1000 req/15min global, 30 tentatives login/15min | +| **Headers de sécurité** | CSP, HSTS, X-Frame-Options, nosniff… | | **Chiffrement des tokens** | Tokens Facebook/Instagram chiffrés (AES-256) | | **Gestion multi-utilisateurs** | Rôles Admin / User, permissions par page | | **Console SQL** | Requêtes directes en base (admin uniquement) | @@ -117,43 +117,53 @@ Architecture Production-Ready avec sécurité renforcée. socialflow/ ├── client/ # Frontend React (Vite + TailwindCSS) │ └── src/ -│ ├── pages/ # 17 pages desktop + 13 pages mobile -│ ├── components/ # 47 composants UI (Radix/shadcn) -│ └── hooks/ # Hooks React personnalisés -├── server/ # Backend Express.js (TypeScript) -│ ├── routes.ts # 45+ endpoints REST API -│ ├── routes/ # Routes modulaires (analytics, reels) -│ ├── services/ # 12 services métier -│ │ ├── analytics.ts # Sync Facebook Graph API -│ │ ├── facebook.ts # Publication Facebook/Instagram -│ │ ├── openrouter.ts # Génération texte IA -│ │ ├── cloudinary.ts # Upload cloud médias -│ │ ├── ffmpeg.ts # Encodage vidéo (microservice) -│ │ ├── freesound.ts # Musique libre de droits -│ │ ├── jamendo.ts # Musique libre de droits -│ │ ├── imageProcessor.ts # Traitement images (Sharp) -│ │ ├── scheduler.ts # Publications programmées -│ │ └── token_manager.ts # Gestion tokens OAuth -│ └── cron.ts # Tâches planifiées -├── shared/ # Schéma DB partagé (Drizzle ORM) +│ ├── pages/ # Pages desktop + mobile +│ ├── components/ # Composants UI (Radix/shadcn) +│ └── remotion/ # Compositions vidéo des Reels (aperçu + rendu) +├── server/ # Backend Rust (axum + sqlx) +│ ├── Cargo.toml +│ └── src/ +│ ├── main.rs # Démarrage : migrations, routes, tâches de fond +│ ├── migrate.rs # Schéma PostgreSQL, mis à jour au démarrage +│ ├── auth.rs # Sessions (cookie connect.sid) et rôles +│ ├── db/ # Accès aux données +│ ├── routes/ # API REST (/api, /api/v1 externe, pages légales) +│ └── services/ # Facebook, TikTok, OpenRouter, Reels, scheduler, crons +├── shared/ # Types et logique partagés par le frontend +├── remotion-service/ # Rendu des Reels avec Remotion (Node + Chrome) ├── ffmpeg-service/ # Microservice FFmpeg (Python/FastAPI) -├── docker-compose.yml # Stack Docker (3 services) -└── Dockerfile # Image production Node.js +├── qwen-tts/ # Voix Qwen3-TTS (optionnel) +├── docker-compose.yml # Stack Docker +└── Dockerfile # Image de l'application (Rust + frontend compilé) ``` ### Stack Technique | Couche | Technologies | | ---------- | --------------------------------------------------------------------------- | -| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Framer Motion, Wouter | -| **Backend** | Express.js, TypeScript, Passport.js, Helmet, node-cron | -| **BDD** | PostgreSQL 16, Drizzle ORM, connect-pg-simple (sessions) | +| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Wouter | +| **Backend** | Rust : axum, tokio, sqlx, reqwest | +| **BDD** | PostgreSQL 16 (schéma créé et migré automatiquement au démarrage) | | **IA** | OpenRouter (multi-modèle : GPT-4, Claude, Gemini, etc.) | -| **Médias** | Cloudinary (upload), Sharp (images), FFmpeg microservice (vidéo) | -| **Musique** | FreeSound API, Jamendo API | -| **Réseaux** | Facebook Graph API v19 (Pages, Instagram, Insights) | +| **Médias** | Stockage local, FFmpeg (vignettes, microservice vidéo), Remotion (Reels) | +| **Réseaux** | Facebook Graph API (Pages, Instagram, Insights), TikTok Content Posting | | **Infra** | Docker Compose, Nginx reverse proxy | +### Mise à jour + +L'image de l'application est construite à partir des sources (`pull_policy: build`). +Après un `git pull`, le prochain démarrage de la stack reconstruit l'image et +applique la nouvelle version : + +```bash +git pull +docker compose up -d --build +``` + +Au démarrage, le serveur met le schéma de la base à jour tout seul (aucune +commande de migration à lancer). Les données, les médias, la clé de chiffrement +et les sessions ouvertes sont conservés. + --- ## 🚀 Installation Rapide @@ -161,7 +171,7 @@ socialflow/ ### Prérequis - [Docker](https://www.docker.com/) & Docker Compose -- [Node.js](https://nodejs.org/) v18+ (pour le développement local) +- Pour le développement local : [Rust](https://rustup.rs/) (stable), [Node.js](https://nodejs.org/) 20+ et PostgreSQL ### 1. Récupérer l'application @@ -199,11 +209,12 @@ FACEBOOK_APP_SECRET=... ```bash # Production (Docker) -docker-compose up -d +docker compose up -d --build -# Développement local +# Développement local (deux terminaux) npm install -npm run dev +DATABASE_URL=postgresql://... npm run dev:server # API Rust sur le port 5000 +npm run dev # Frontend Vite, relaie /api vers le port 5000 ``` 🚀 Rendez-vous sur **`http://localhost:5555`** pour commencer ! diff --git a/docker-compose.yml b/docker-compose.yml index 1ea68ae..e1003ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,6 @@ services: - media_uploads:/app/uploads/media - logos_uploads:/app/uploads/logos - stories_uploads:/app/uploads/stories - command: npm run start # Service FFmpeg dédié ffmpeg-api: diff --git a/package-lock.json b/package-lock.json index ba24b2d..7df548d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@aws-sdk/client-s3": "^3.1016.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource/montserrat": "^5.3.0", "@hookform/resolvers": "^3.10.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -44,46 +41,20 @@ "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", - "@remotion/bundler": "^4.0.438", - "@remotion/cli": "^4.0.438", "@remotion/player": "^4.0.438", - "@remotion/renderer": "^4.0.438", "@tanstack/react-query": "^5.60.5", - "@twemoji/api": "^17.0.2", - "@types/bcrypt": "^6.0.0", - "@types/multer": "^2.0.0", - "@types/node-cron": "^3.0.11", - "@types/node-fetch": "^2.6.13", - "@types/pg": "^8.15.5", - "bcrypt": "^6.0.0", - "canvas": "^2.11.2", "class-variance-authority": "^0.7.1", - "cloudinary": "^2.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", - "emoji-regex": "^10.6.0", - "express": "^4.21.2", - "express-rate-limit": "^7.5.1", - "express-session": "^1.18.1", "framer-motion": "^11.13.1", - "helmet": "^8.1.0", "html-to-image": "^1.11.13", - "html2canvas": "^1.4.1", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", - "memorystore": "^1.6.7", - "multer": "^2.0.2", - "music-metadata": "^11.12.3", "next-themes": "^0.4.6", - "node-cron": "^4.2.1", - "passport": "^0.7.0", - "passport-local": "^1.0.0", - "pg": "^8.16.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -94,15 +65,12 @@ "react-webcam": "^7.2.0", "recharts": "^2.15.2", "remotion": "^4.0.438", - "sharp": "^0.34.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", "twemoji": "^14.0.2", - "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5", - "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" }, @@ -112,28 +80,16 @@ "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/connect-pg-simple": "^7.0.3", - "@types/express": "4.17.21", - "@types/express-session": "^1.18.0", "@types/node": "20.16.11", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", - "@types/ws": "^8.5.13", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", - "drizzle-kit": "^0.31.4", - "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", - "tsx": "^4.20.5", "typescript": "5.6.3", "vite": "^5.4.20", "vitest": "^3.2.7" - }, - "optionalDependencies": { - "bufferutil": "^4.0.8" } }, "node_modules/@alloc/quick-lru": { @@ -148,857 +104,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1016.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1016.0.tgz", - "integrity": "sha512-E9umet1PolP6I8TpjQQ2W88aIIguyiRQJE98ag6N6QeLgjSZsF+h9l3KclwCRvqUFU68x+HRwrgXxvbIBVFLbA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/credential-provider-node": "^3.972.25", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", - "@aws-sdk/middleware-expect-continue": "^3.972.8", - "@aws-sdk/middleware-flexible-checksums": "^3.974.4", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-location-constraint": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-sdk-s3": "^3.972.24", - "@aws-sdk/middleware-ssec": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.25", - "@aws-sdk/region-config-resolver": "^3.972.9", - "@aws-sdk/signature-v4-multi-region": "^3.996.12", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.11", - "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", - "@smithy/eventstream-serde-browser": "^4.2.12", - "@smithy/eventstream-serde-config-resolver": "^4.3.12", - "@smithy/eventstream-serde-node": "^4.2.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-blob-browser": "^4.2.13", - "@smithy/hash-node": "^4.2.12", - "@smithy/hash-stream-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/md5-js": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.43", - "@smithy/util-defaults-mode-node": "^4.2.47", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-stream": "^4.5.20", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.13", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.24.tgz", - "integrity": "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/xml-builder": "^3.972.15", - "@smithy/core": "^3.23.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", - "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.22.tgz", - "integrity": "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.24.tgz", - "integrity": "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/types": "^3.973.6", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.20", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.24.tgz", - "integrity": "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/credential-provider-env": "^3.972.22", - "@aws-sdk/credential-provider-http": "^3.972.24", - "@aws-sdk/credential-provider-login": "^3.972.24", - "@aws-sdk/credential-provider-process": "^3.972.22", - "@aws-sdk/credential-provider-sso": "^3.972.24", - "@aws-sdk/credential-provider-web-identity": "^3.972.24", - "@aws-sdk/nested-clients": "^3.996.14", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.24.tgz", - "integrity": "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/nested-clients": "^3.996.14", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.25.tgz", - "integrity": "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.22", - "@aws-sdk/credential-provider-http": "^3.972.24", - "@aws-sdk/credential-provider-ini": "^3.972.24", - "@aws-sdk/credential-provider-process": "^3.972.22", - "@aws-sdk/credential-provider-sso": "^3.972.24", - "@aws-sdk/credential-provider-web-identity": "^3.972.24", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.22.tgz", - "integrity": "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.24.tgz", - "integrity": "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/nested-clients": "^3.996.14", - "@aws-sdk/token-providers": "3.1015.0", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.24.tgz", - "integrity": "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/nested-clients": "^3.996.14", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", - "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", - "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.4.tgz", - "integrity": "sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/crc64-nvme": "^3.972.5", - "@aws-sdk/types": "^3.973.6", - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.20", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", - "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", - "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", - "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", - "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.24.tgz", - "integrity": "sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.20", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", - "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.25.tgz", - "integrity": "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@smithy/core": "^3.23.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-retry": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.14.tgz", - "integrity": "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.25", - "@aws-sdk/region-config-resolver": "^3.972.9", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.11", - "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.43", - "@smithy/util-defaults-mode-node": "^4.2.47", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.9.tgz", - "integrity": "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/config-resolver": "^4.4.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.12.tgz", - "integrity": "sha512-abRObSqjVeKUUHIZfAp78PTYrEsxCgVKDs/YET357pzT5C02eDDEvmWyeEC2wglWcYC4UTbBFk22gd2YJUlCQg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.24", - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1015.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1015.0.tgz", - "integrity": "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.24", - "@aws-sdk/nested-clients": "^3.996.14", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", - "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", - "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-endpoints": "^3.3.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", - "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.11.tgz", - "integrity": "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.25", - "@aws-sdk/types": "^3.973.6", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", - "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1292,16 +397,6 @@ "node": ">=6.9.0" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1355,922 +450,6 @@ "react": ">=16.8.0" } }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@floating-ui/core": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", @@ -2322,433 +501,6 @@ "react-hook-form": "^7.0.0" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.5.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2800,7 +552,10 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2822,147 +577,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@mediabunny/aac-encoder": { - "version": "1.39.2", - "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.39.2.tgz", - "integrity": "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA==", - "license": "MPL-2.0", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/Vanilagy" - }, - "peerDependencies": { - "mediabunny": "^1.0.0" - } - }, - "node_modules/@mediabunny/flac-encoder": { - "version": "1.39.2", - "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.39.2.tgz", - "integrity": "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ==", - "license": "MPL-2.0", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/Vanilagy" - }, - "peerDependencies": { - "mediabunny": "^1.0.0" - } - }, - "node_modules/@mediabunny/mp3-encoder": { - "version": "1.39.2", - "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.39.2.tgz", - "integrity": "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw==", - "license": "MPL-2.0", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/Vanilagy" - }, - "peerDependencies": { - "mediabunny": "^1.0.0" - } - }, - "node_modules/@module-federation/error-codes": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", - "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", - "license": "MIT" - }, - "node_modules/@module-federation/runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", - "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/runtime-core": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-core": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", - "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-tools": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", - "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/webpack-bundler-runtime": "0.22.0" - } - }, - "node_modules/@module-federation/sdk": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", - "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", - "license": "MIT" - }, - "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", - "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, "node_modules/@neondatabase/serverless": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/pg": "8.11.6" } @@ -2972,6 +593,8 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2983,6 +606,8 @@ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", @@ -3001,6 +626,8 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -3010,6 +637,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "obuf": "~1.1.2" }, @@ -3022,6 +651,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -3031,6 +662,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -4344,632 +1977,6 @@ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" }, - "node_modules/@remotion/bundler": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.438.tgz", - "integrity": "sha512-2CVJhgQvdYwh+ioYMZ5xIA+22NIX1/sARTOqDd9nRzxrueTuHN3/U0fhygdwz6TsoVqM9EuDsP1RPSeUbXURpw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@remotion/media-parser": "4.0.438", - "@remotion/studio": "4.0.438", - "@remotion/studio-shared": "4.0.438", - "@rspack/core": "1.7.6", - "@rspack/plugin-react-refresh": "1.6.1", - "css-loader": "5.2.7", - "esbuild": "0.25.0", - "react-refresh": "0.18.0", - "remotion": "4.0.438", - "source-map": "0.7.3", - "style-loader": "4.0.0", - "webpack": "5.105.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@remotion/bundler/node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, - "node_modules/@remotion/bundler/node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@remotion/bundler/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@remotion/cli": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.438.tgz", - "integrity": "sha512-/OI6iTFZckb8vbpLcL4jpdUONPSo1cB6nn1464XCpTOIgV+Tm5gwW45Vh6ffaPqW3uc0zuy5psorJgt8f1llJg==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@remotion/bundler": "4.0.438", - "@remotion/media-utils": "4.0.438", - "@remotion/player": "4.0.438", - "@remotion/renderer": "4.0.438", - "@remotion/studio": "4.0.438", - "@remotion/studio-server": "4.0.438", - "@remotion/studio-shared": "4.0.438", - "dotenv": "17.3.1", - "minimist": "1.2.6", - "prompts": "2.4.2", - "remotion": "4.0.438" - }, - "bin": { - "remotion": "remotion-cli.js", - "remotionb": "remotionb-cli.js", - "remotiond": "remotiond-cli.js" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@remotion/cli/node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "license": "MIT" - }, - "node_modules/@remotion/compositor-darwin-arm64": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.438.tgz", - "integrity": "sha512-iRw31eVGczU9gdvJtdNttX09dy9RyZfO/yI+Vtl6LNMWemKrLLuup2gDbU7i7QVw2a6vUH+cv/2eLUtbKdvQJw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@remotion/compositor-darwin-x64": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.438.tgz", - "integrity": "sha512-BOupRCn0yYGD1SA5hHcCBoiDb2pPQEkagGFhhefAsJM9mK5blhkRCCCSRMm9kvUCEIUAAEXKZ5NpXgtUFsiqsw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@remotion/compositor-linux-arm64-gnu": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.438.tgz", - "integrity": "sha512-7S1bDtERk/sQVp6ETKD+Ici0Euq7jo1qaYQQz4eqKqik3S/pPGnOnRdPiArA4W9VR3voCj3asIsuk+A0hDu2hw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@remotion/compositor-linux-arm64-musl": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.438.tgz", - "integrity": "sha512-n6U9rS3ipTd3pLcKF4WxGbdD+Hte7YgkTpDFfX6GRFZTz41d8q40xMKlgNiGmPPOfLyKtY+Af5K0RKAK6wvIkQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@remotion/compositor-linux-x64-gnu": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.438.tgz", - "integrity": "sha512-IhEOAYphOLETvq8dWllii201YOteukoS2hCwj4jgv39A5slIbyBxUS6TfuFzel2PghwcJDxNLKzbbJAJ0P89ng==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@remotion/compositor-linux-x64-musl": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.438.tgz", - "integrity": "sha512-qOmPquSwDTcP8JkdjbLdnbjZv+5BLRSo3BOqIGIcA5MBPOs2fZGM4MAFtO1Cd7IP6M2rzwawcVzplHbWUjn9FA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@remotion/compositor-win32-x64-msvc": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.438.tgz", - "integrity": "sha512-uso+PNYVQax2wZekkDsfPQrrRT35wySGf6sC2CbQXiwBeHAtsksZZlPpM/N00Rg227ew9jVit7pevdxwt7AD6A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@remotion/licensing": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.438.tgz", - "integrity": "sha512-JYg8ebbVkDJDnKt0BZV7OEptAysKkIWTdRPVGdwPeNw6CMcCooPPDuR7gGHjPtm0Odn/A3EKUopCX3kTvyWTPQ==", - "license": "MIT" - }, - "node_modules/@remotion/media-parser": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.438.tgz", - "integrity": "sha512-BDqu70nW0BAlQhSn0wYUo/r47MphvYJ0pHz4/UW3PRkiYn3iOIIF9/PO4MM2nomBxirMzGoRfxp0aUbTimTyXg==", - "license": "Remotion License https://remotion.dev/license" - }, - "node_modules/@remotion/media-utils": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.438.tgz", - "integrity": "sha512-ncXhPkpO2RTEYFhtHmeWgQiXJQ3JLFVx3q7h4N5DtI8CXn0A3lOuzExkUaVYM6Gr8dTwoStLbYnt+WX9Ev6Lxg==", - "license": "MIT", - "dependencies": { - "mediabunny": "1.39.2", - "remotion": "4.0.438" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/@remotion/player": { "version": "4.0.438", "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.438.tgz", @@ -4983,288 +1990,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/@remotion/renderer": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.438.tgz", - "integrity": "sha512-gTQZ3o8bN7oZIj4aN7hRQ+q5ycOZ1J4TEcGCOXtsLscBbGqiq/dUedDjDWrtdd+VUrPXP3V7bhn7+yWI5/1sHA==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@remotion/licensing": "4.0.438", - "@remotion/streaming": "4.0.438", - "execa": "5.1.1", - "extract-zip": "2.0.1", - "remotion": "4.0.438", - "source-map": "^0.8.0-beta.0", - "ws": "8.17.1" - }, - "optionalDependencies": { - "@remotion/compositor-darwin-arm64": "4.0.438", - "@remotion/compositor-darwin-x64": "4.0.438", - "@remotion/compositor-linux-arm64-gnu": "4.0.438", - "@remotion/compositor-linux-arm64-musl": "4.0.438", - "@remotion/compositor-linux-x64-gnu": "4.0.438", - "@remotion/compositor-linux-x64-musl": "4.0.438", - "@remotion/compositor-win32-x64-msvc": "4.0.438" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@remotion/renderer/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", - "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@remotion/renderer/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/@remotion/renderer/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "license": "BSD-2-Clause" - }, - "node_modules/@remotion/renderer/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/@remotion/renderer/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@remotion/streaming": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.438.tgz", - "integrity": "sha512-D0mTdQzpmP9RilnxHCvkTVFuE2FQ0SUw2DzAJQ3KBkBC4tBL6mVUoT289NsqjhNxTLmvZfi4LN85U8kpz03CpQ==", - "license": "MIT" - }, - "node_modules/@remotion/studio": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.438.tgz", - "integrity": "sha512-qGsHigArmTFGrdKKQyWM5WiHwp7fN00W6L1hHB3PXiSyCTGeyHQ5EMgdVlFQL59QXidRMINGKk1pMSR2vzWo8w==", - "license": "MIT", - "dependencies": { - "@remotion/media-utils": "4.0.438", - "@remotion/player": "4.0.438", - "@remotion/renderer": "4.0.438", - "@remotion/studio-shared": "4.0.438", - "@remotion/web-renderer": "4.0.438", - "@remotion/zod-types": "4.0.438", - "mediabunny": "1.39.2", - "memfs": "3.4.3", - "open": "^8.4.2", - "remotion": "4.0.438", - "semver": "7.5.3", - "source-map": "0.7.3", - "zod": "4.3.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@remotion/studio-server": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.438.tgz", - "integrity": "sha512-GAroJUlp+q3X+wEwuP15E1DKAvJb9P106HGzx+BBCf7RvuWXoL0dTL5GSpnAnrM4ODRGE5RvcH98ru4OiEtuYA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "7.24.1", - "@remotion/bundler": "4.0.438", - "@remotion/renderer": "4.0.438", - "@remotion/studio-shared": "4.0.438", - "memfs": "3.4.3", - "open": "^8.4.2", - "prettier": "3.8.1", - "recast": "0.23.11", - "remotion": "4.0.438", - "semver": "7.5.3", - "source-map": "0.7.3" - } - }, - "node_modules/@remotion/studio-server/node_modules/@babel/parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", - "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", - "license": "MIT", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@remotion/studio-server/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@remotion/studio-server/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@remotion/studio-server/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@remotion/studio-server/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/@remotion/studio-shared": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.438.tgz", - "integrity": "sha512-c/92eFDRCDTltZKEna8J9MWaCpONgfDThzhpp+z8geg0d8HeeYFenvKd1NxH9ihuJg09HZLXTV5u9hUpnUj1wA==", - "license": "MIT", - "dependencies": { - "remotion": "4.0.438" - } - }, - "node_modules/@remotion/studio/node_modules/@remotion/zod-types": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.438.tgz", - "integrity": "sha512-Rif8RJO5JpACl8EPjgvufAeg0nujI5eBgDdFKmiysEtjGq2E6G1nwPcBWSxwQLY8L++9FzY1hidFl7DBjWF9yA==", - "license": "MIT", - "dependencies": { - "remotion": "4.0.438" - }, - "peerDependencies": { - "zod": "4.3.6" - } - }, - "node_modules/@remotion/studio/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@remotion/studio/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@remotion/studio/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@remotion/studio/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/@remotion/studio/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@remotion/web-renderer": { - "version": "4.0.438", - "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.438.tgz", - "integrity": "sha512-cOGtgqRiD+q77Vj65gy/fo+GK1wA3G5ft2xYLPJZzddmrhhrhibyFglljY2Vu7UaXYIUWZTkGoXwdKchC09UZA==", - "license": "UNLICENSED", - "dependencies": { - "@mediabunny/aac-encoder": "1.39.2", - "@mediabunny/flac-encoder": "1.39.2", - "@mediabunny/mp3-encoder": "1.39.2", - "@remotion/licensing": "4.0.438", - "mediabunny": "1.39.2", - "remotion": "4.0.438" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "node_modules/@replit/vite-plugin-cartographer": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz", @@ -5552,934 +2277,6 @@ "win32" ] }, - "node_modules/@rspack/binding": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.6.tgz", - "integrity": "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==", - "license": "MIT", - "optionalDependencies": { - "@rspack/binding-darwin-arm64": "1.7.6", - "@rspack/binding-darwin-x64": "1.7.6", - "@rspack/binding-linux-arm64-gnu": "1.7.6", - "@rspack/binding-linux-arm64-musl": "1.7.6", - "@rspack/binding-linux-x64-gnu": "1.7.6", - "@rspack/binding-linux-x64-musl": "1.7.6", - "@rspack/binding-wasm32-wasi": "1.7.6", - "@rspack/binding-win32-arm64-msvc": "1.7.6", - "@rspack/binding-win32-ia32-msvc": "1.7.6", - "@rspack/binding-win32-x64-msvc": "1.7.6" - } - }, - "node_modules/@rspack/binding-darwin-arm64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.6.tgz", - "integrity": "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rspack/binding-darwin-x64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.6.tgz", - "integrity": "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.6.tgz", - "integrity": "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.6.tgz", - "integrity": "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.6.tgz", - "integrity": "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.6.tgz", - "integrity": "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.6.tgz", - "integrity": "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" - } - }, - "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.6.tgz", - "integrity": "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.6.tgz", - "integrity": "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.6.tgz", - "integrity": "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.6.tgz", - "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", - "license": "MIT", - "dependencies": { - "@module-federation/runtime-tools": "0.22.0", - "@rspack/binding": "1.7.6", - "@rspack/lite-tapable": "1.1.0" - }, - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.1" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@rspack/lite-tapable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", - "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", - "license": "MIT" - }, - "node_modules/@rspack/plugin-react-refresh": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", - "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", - "license": "MIT", - "dependencies": { - "error-stack-parser": "^2.1.4", - "html-entities": "^2.6.0" - }, - "peerDependencies": { - "react-refresh": ">=0.10.0 <1.0.0", - "webpack-hot-middleware": "2.x" - }, - "peerDependenciesMeta": { - "webpack-hot-middleware": { - "optional": true - } - } - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", - "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", - "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", - "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.13", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", - "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.23.12", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", - "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.20", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", - "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", - "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.1", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", - "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", - "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", - "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", - "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", - "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.2", - "@smithy/chunked-blob-reader-native": "^4.2.3", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", - "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", - "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", - "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", - "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", - "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.27", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", - "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-serde": "^4.2.15", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.44", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", - "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", - "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", - "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", - "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", - "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.20", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.43", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", - "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.47", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", - "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.13", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", - "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.20", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", - "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", - "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@tailwindcss/node": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", @@ -6781,57 +2578,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", - "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, - "node_modules/@twemoji/api": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/@twemoji/api/-/api-17.0.2.tgz", - "integrity": "sha512-5xIdBcFLpRW4goXNdNNz497TzMHF2a76yElEaqISxVPj+KpJB82KuXHczfjhhTCsPaP1lAhM3qPtvAhMG7e71A==", - "license": "MIT AND CC-BY-4.0", - "dependencies": { - "@twemoji/parser": "17.0.1", - "fs-extra": "^8.0.1", - "jsonfile": "^5.0.0", - "universalify": "^0.1.2" - } - }, - "node_modules/@twemoji/parser": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/@twemoji/parser/-/parser-17.0.1.tgz", - "integrity": "sha512-Uiq5sq1Wx91Y6C0DczMlu4Gj4nC/0z8UIhT7S53hi/JATaS1oLM5jXygjot7STtyVoIagfnpsBCWaNy3ayfyFw==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6877,25 +2623,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -6907,27 +2634,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-pg-simple": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", - "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/express-session": "*", - "@types/pg": "*" - } - }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -6998,171 +2704,30 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/dom-mediacapture-transform": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", - "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", - "license": "MIT", - "dependencies": { - "@types/dom-webcodecs": "*" - } - }, - "node_modules/@types/dom-webcodecs": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", - "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", - "license": "MIT" - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/express-session": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", - "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/node": { "version": "20.16.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, - "node_modules/@types/node-cron": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", - "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", - "license": "MIT" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/passport": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", - "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/passport-local": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", - "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, - "node_modules/@types/passport-strategy": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", - "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*" - } - }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -7176,18 +2741,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", - "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", @@ -7209,47 +2762,6 @@ "@types/react": "*" } }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", - "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -7359,188 +2871,14 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7548,94 +2886,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -7679,32 +2929,6 @@ "node": ">= 8" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -7723,12 +2947,6 @@ "node": ">=10" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7739,24 +2957,6 @@ "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -7810,19 +3010,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7831,29 +3023,6 @@ "node": ">=6.0.0" } }, - "node_modules/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7866,51 +3035,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -7936,6 +3060,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7965,54 +3090,14 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bufferutil": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", - "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", - "hasInstallScript": true, + "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "peer": true }, "node_modules/cac": { "version": "6.7.14", @@ -8024,38 +3109,6 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -8069,6 +3122,7 @@ "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8085,21 +3139,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", - "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "nan": "^2.17.0", - "simple-get": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -8163,24 +3202,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -8192,19 +3213,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cloudinary": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz", - "integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "q": "^1.5.1" - }, - "engines": { - "node": ">=9" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -8246,27 +3254,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -8276,66 +3263,6 @@ "node": ">= 6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/connect-pg-simple": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", - "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", - "license": "MIT", - "dependencies": { - "pg": "^8.12.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=22.0.0" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -8343,21 +3270,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8372,55 +3284,6 @@ "node": ">= 8" } }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/css-loader": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", - "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", - "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.15", - "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.0", - "postcss-modules-scope": "^3.0.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.1.0", - "schema-utils": "^3.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.27.0 || ^5.0.0" - } - }, - "node_modules/css-loader/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -8573,6 +3436,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8592,18 +3456,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/decompress-response": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", - "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "license": "MIT", - "dependencies": { - "mimic-response": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -8614,70 +3466,11 @@ "node": ">=6" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -8709,34 +3502,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/drizzle-kit": { - "version": "0.31.4", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz", - "integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "esbuild-register": "^3.5.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, "node_modules/drizzle-orm": { "version": "0.39.1", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.39.1.tgz", @@ -8872,36 +3637,17 @@ "zod": ">=3.0.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.322", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.322.tgz", "integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==", + "dev": true, "license": "ISC" }, "node_modules/embla-carousel": { @@ -8929,43 +3675,11 @@ "embla-carousel": "8.6.0" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8975,192 +3689,16 @@ "node": ">=10.13.0" } }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -9171,59 +3709,12 @@ "@types/estree": "^1.0.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -9234,172 +3725,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-session": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", - "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.0.2", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express-session/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", @@ -9436,63 +3761,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -9502,15 +3770,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -9523,24 +3782,6 @@ "node": ">= 12" } }, - "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9553,39 +3794,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -9602,31 +3810,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -9668,15 +3851,6 @@ } } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -9700,48 +3874,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/fs-monkey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", - "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", - "license": "Unlicense" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -9765,74 +3897,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gauge/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/gauge/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9843,30 +3907,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -9875,44 +3915,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", - "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -9945,83 +3947,11 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -10034,149 +3964,12 @@ "node": ">= 0.4" } }, - "node_modules/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -10195,15 +3988,6 @@ "node": ">=12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -10231,21 +4015,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10285,30 +4054,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -10330,20 +4075,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/jiti": { "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", @@ -10372,22 +4103,11 @@ "node": ">=6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -10408,15 +4128,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", @@ -10662,33 +4373,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -10716,12 +4400,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10768,112 +4446,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mediabunny": { - "version": "1.39.2", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz", - "integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==", - "license": "MPL-2.0", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@types/dom-mediacapture-transform": "^0.1.11", - "@types/dom-webcodecs": "0.1.13" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/Vanilagy" - } - }, - "node_modules/memfs": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", - "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", - "license": "Unlicense", - "dependencies": { - "fs-monkey": "1.0.3" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/memorystore": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", - "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.0", - "lru-cache": "^4.0.3" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/memorystore/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/memorystore/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "license": "ISC" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10883,15 +4455,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -10905,60 +4468,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", - "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -10974,15 +4483,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -10992,55 +4492,12 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/modern-screenshot": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.0.tgz", @@ -11061,66 +4518,9 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, - "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, - "node_modules/music-metadata": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", - "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - }, - { - "type": "buymeacoffee", - "url": "https://buymeacoffee.com/borewit" - } - ], - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.2", - "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "file-type": "^21.3.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", - "token-types": "^6.1.2", - "uint8array-extras": "^1.5.0", - "win-guid": "^0.2.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/music-metadata/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -11132,12 +4532,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nan": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", - "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -11155,21 +4549,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -11179,76 +4558,13 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-cron": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", - "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11268,31 +4584,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11311,85 +4602,13 @@ "node": ">= 6" } }, - "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "peer": true }, "node_modules/package-json-from-dist": { "version": "1.0.1", @@ -11397,76 +4616,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/passport": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", - "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "license": "MIT", - "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11504,12 +4653,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -11527,22 +4670,13 @@ "node": ">= 14.16" } }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11570,19 +4704,24 @@ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/pg-connection-string": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -11592,6 +4731,8 @@ "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -11601,6 +4742,8 @@ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "pg": ">=8.0" } @@ -11609,13 +4752,17 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -11632,6 +4779,8 @@ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "split2": "^4.1.0" } @@ -11771,91 +4920,6 @@ } } }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "license": "MIT", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/postcss-nested": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", @@ -11905,6 +4969,8 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -11914,6 +4980,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11923,6 +4991,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11932,6 +5002,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -11943,35 +5015,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" - }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } + "optional": true, + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -11988,70 +5034,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "license": "ISC" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "license": "MIT", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12072,39 +5054,6 @@ ], "license": "MIT" }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -12194,6 +5143,7 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12322,20 +5272,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -12348,22 +5284,6 @@ "node": ">=8.10.0" } }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, "node_modules/recharts": { "version": "2.15.2", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", @@ -12419,15 +5339,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -12445,16 +5356,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -12465,65 +5366,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rollup": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz", @@ -12585,32 +5427,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -12620,179 +5436,16 @@ "loose-envify": "^1.1.0" } }, - "node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12814,24 +5467,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -12851,48 +5486,14 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", - "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", - "license": "MIT", - "dependencies": { - "decompress-response": "^4.2.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12910,7 +5511,10 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -12921,6 +5525,8 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">= 10.x" } @@ -12932,21 +5538,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -12954,23 +5545,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -13073,15 +5647,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -13102,50 +5667,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/strtok3": { - "version": "10.3.5", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", - "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/style-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", - "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", - "license": "MIT", - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.27.0" - } - }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -13168,21 +5689,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -13252,6 +5758,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13261,55 +5768,14 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/terser": { "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -13323,106 +5789,14 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } + "optional": true, + "peer": true }, "node_modules/thenify": { "version": "3.3.1", @@ -13555,39 +5929,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -13600,26 +5941,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.20.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", - "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/tw-animate-css": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", @@ -13646,25 +5967,6 @@ "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==", "license": "MIT" }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -13679,34 +5981,11 @@ "node": ">=14.17" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -13718,19 +5997,11 @@ "node": ">= 4.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -13757,15 +6028,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -13822,33 +6084,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -14516,151 +6751,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/webpack": { - "version": "5.105.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", - "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14693,62 +6783,6 @@ "node": ">=8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wide-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wide-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/win-guid": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", - "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", - "license": "MIT" - }, "node_modules/wouter": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz", @@ -14854,38 +6888,13 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.4" } @@ -14909,16 +6918,6 @@ "node": ">= 14" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/zod": { "version": "3.24.2", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", diff --git a/package.json b/package.json index ca77f63..a1f32f8 100644 --- a/package.json +++ b/package.json @@ -4,22 +4,19 @@ "type": "module", "license": "MIT", "scripts": { - "dev": "NODE_ENV=development tsx server/index.ts", - "build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", - "start": "NODE_ENV=production node dist/index.js", + "dev": "vite", + "dev:server": "cargo run --manifest-path server/Cargo.toml", + "build": "vite build", + "start": "cargo run --release --manifest-path server/Cargo.toml", "check": "tsc", - "db:push": "drizzle-kit push", "test": "vitest run" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1016.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource/montserrat": "^5.3.0", "@hookform/resolvers": "^3.10.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -47,46 +44,20 @@ "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", - "@remotion/bundler": "^4.0.438", - "@remotion/cli": "^4.0.438", "@remotion/player": "^4.0.438", - "@remotion/renderer": "^4.0.438", "@tanstack/react-query": "^5.60.5", - "@twemoji/api": "^17.0.2", - "@types/bcrypt": "^6.0.0", - "@types/multer": "^2.0.0", - "@types/node-cron": "^3.0.11", - "@types/node-fetch": "^2.6.13", - "@types/pg": "^8.15.5", - "bcrypt": "^6.0.0", - "canvas": "^2.11.2", "class-variance-authority": "^0.7.1", - "cloudinary": "^2.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", - "emoji-regex": "^10.6.0", - "express": "^4.21.2", - "express-rate-limit": "^7.5.1", - "express-session": "^1.18.1", "framer-motion": "^11.13.1", - "helmet": "^8.1.0", "html-to-image": "^1.11.13", - "html2canvas": "^1.4.1", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", - "memorystore": "^1.6.7", - "multer": "^2.0.2", - "music-metadata": "^11.12.3", "next-themes": "^0.4.6", - "node-cron": "^4.2.1", - "passport": "^0.7.0", - "passport-local": "^1.0.0", - "pg": "^8.16.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -97,15 +68,12 @@ "react-webcam": "^7.2.0", "recharts": "^2.15.2", "remotion": "^4.0.438", - "sharp": "^0.34.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", "twemoji": "^14.0.2", - "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5", - "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" }, @@ -115,27 +83,15 @@ "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/connect-pg-simple": "^7.0.3", - "@types/express": "4.17.21", - "@types/express-session": "^1.18.0", "@types/node": "20.16.11", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", - "@types/ws": "^8.5.13", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", - "drizzle-kit": "^0.31.4", - "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", - "tsx": "^4.20.5", "typescript": "5.6.3", "vite": "^5.4.20", "vitest": "^3.2.7" - }, - "optionalDependencies": { - "bufferutil": "^4.0.8" } } diff --git a/replit.md b/replit.md index edad4b8..951b1fc 100644 --- a/replit.md +++ b/replit.md @@ -32,7 +32,7 @@ Preferred communication style: Simple, everyday language. The frontend is built with React and TypeScript, leveraging Vite for bundling and Wouter for routing. State management is primarily handled by TanStack Query. UI components are constructed using Radix UI primitives and shadcn/ui, styled with Tailwind CSS, emphasizing a component-based, responsive, mobile-first design with real-time data synchronization. Dark mode is the default theme. ### Backend Architecture -The backend is developed in Node.js with Express.js, entirely in TypeScript. It adheres to a RESTful API design with dedicated endpoints for authentication, user management, AI content generation, media handling, social page management, and post scheduling. Multer is utilized for file uploads, integrated with Cloudinary. Node-cron manages background processes for scheduled post publication. The architecture incorporates a service layer, storage abstraction, and a middleware-based request processing pipeline. Session management is handled by `express-session` with `connect-pg-simple`. +The backend is written in Rust (axum, tokio, sqlx) in the `server/` crate. It exposes the same RESTful API as the former Node/Express server (authentication, user management, AI content generation, media handling, social page management, post scheduling, external API). Uploads are stored locally under `uploads/`. Background tasks (scheduled publications, token checks, analytics, media purge, Reel render queue) run as tokio tasks. Sessions are stored in the PostgreSQL `session` table with a signed `connect.sid` cookie, compatible with the former server. The database schema is created and migrated automatically at startup (`server/src/migrate.rs`). Remotion Reel rendering runs in a separate Node sidecar (`remotion-service/`). ### Database Architecture PostgreSQL serves as the database, interfaced using Drizzle ORM for type-safe operations and Neon serverless driver for WebSocket support. The schema includes tables for `users`, `cloudinary_config`, `social_pages`, `media`, `posts`, `post_media`, `scheduled_posts`, and `ai_generations`. Drizzle Kit is used for schema migrations. diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index ccfe00e..584bdea 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -24,6 +24,7 @@ use crate::state::AppState; pub fn router(state: AppState) -> Router { let api = Router::new() + .route("/healthz", axum::routing::get(healthz)) .merge(auth::router()) .merge(core::router()) .nest("/api/analytics", analytics::router()) @@ -56,6 +57,14 @@ pub fn router(state: AppState) -> Router { .with_state(state) } +/// Sonde de santé (Docker) : le serveur répond et la base est joignable. +async fn healthz(State(state): State) -> Response { + match sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db.pool).await { + Ok(_) => (StatusCode::OK, "ok").into_response(), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable").into_response(), + } +} + /// 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(); diff --git a/tsconfig.json b/tsconfig.json index a0203ee..16d7875 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "include": ["client/src/**/*", "shared/**/*"], "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], "compilerOptions": { "incremental": true, diff --git a/vite.config.ts b/vite.config.ts index d86c4b8..3925759 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,12 @@ export default defineConfig({ emptyOutDir: true, }, server: { + // En développement, l'API et les fichiers sont servis par le serveur Rust + // (`npm run dev:server`, port 5000 par défaut). + proxy: { + "/api": process.env.API_URL ?? "http://localhost:5000", + "/uploads": process.env.API_URL ?? "http://localhost:5000", + }, fs: { strict: true, deny: ["**/.*"], diff --git a/vitest.config.ts b/vitest.config.ts index 48de4c5..740843a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, }, test: { - include: ["shared/**/*.test.ts", "server/**/*.test.ts"], + include: ["shared/**/*.test.ts"], environment: "node", }, }); From 73e4a4c82205b2de70736fb7e9e35332158a8f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:20:59 +0000 Subject: [PATCH 03/10] =?UTF-8?q?Test=20de=20compatibilit=C3=A9=20du=20d?= =?UTF-8?q?=C3=A9chiffrement=20avec=20l'ancien=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- server/src/crypto.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/crypto.rs b/server/src/crypto.rs index e64a224..3b03df3 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -148,5 +148,10 @@ mod tests { assert!(is_encrypted(&sealed)); assert_eq!(decrypt(&sealed), "EAAB-token"); assert_eq!(decrypt("plain-token"), "plain-token"); + // Valeur chiffrée par l'ancien serveur Node avec la même clé + assert_eq!( + decrypt("b313bfc2eec1a7b8c9743a580c75561b:348a147444c5ef2ee708e5b8bb72d5d0:aebf8e28c21928d5ad207d48d210f4"), + "EAAB-node-token" + ); } } From 9d14b2a5f0700ad85a832b85480014323611a333 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:24:14 +0000 Subject: [PATCH 04/10] Serveur Rust : formatage et corrections clippy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- server/src/auth.rs | 78 +++-- server/src/crypto.rs | 23 +- server/src/db/mod.rs | 447 ++++++++++++++++++++++------- server/src/db/models.rs | 5 +- server/src/error.rs | 5 +- server/src/http.rs | 44 ++- server/src/migrate.rs | 91 ++++-- server/src/public_url.rs | 6 +- server/src/routes/auth.rs | 28 +- server/src/routes/mod.rs | 35 ++- server/src/services/audio.rs | 9 +- server/src/services/ffmpeg.rs | 5 +- server/src/services/media_store.rs | 53 +++- server/src/services/thumbnail.rs | 21 +- server/src/state.rs | 5 +- server/src/time.rs | 29 +- server/src/upload.rs | 13 +- 17 files changed, 690 insertions(+), 207 deletions(-) diff --git a/server/src/auth.rs b/server/src/auth.rs index fa95301..1441544 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -57,7 +57,10 @@ impl Session { } pub fn get_str(&self, key: &str) -> Option { - self.data.get(key).and_then(Value::as_str).map(str::to_string) + self.data + .get(key) + .and_then(Value::as_str) + .map(str::to_string) } pub fn set(&mut self, key: &str, value: Value) { @@ -86,7 +89,8 @@ impl Session { } fn sign(secret: &str, value: &str) -> String { - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé"); + 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('=')) @@ -142,9 +146,15 @@ fn cookie_meta(secure: bool, expires: Ts) -> Value { } /// En-tête `Set-Cookie` de la session. -pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: Ts) -> HeaderValue { +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 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") @@ -157,19 +167,22 @@ pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: /// 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") + 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()?; + 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 }) } @@ -201,7 +214,10 @@ pub async fn start_session( } 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?; + sqlx::query("DELETE FROM session WHERE sid = $1") + .bind(sid) + .execute(&state.db.pool) + .await?; Ok(()) } @@ -252,7 +268,10 @@ impl Deref for AuthUser { impl FromRequestParts for AuthUser { type Rejection = AppError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + 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é")), @@ -277,10 +296,15 @@ impl Deref for AdminUser { impl FromRequestParts for AdminUser { type Rejection = AppError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + 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.")); + return Err(AppError::forbidden( + "Accès refusé. Réservé aux administrateurs.", + )); } Ok(AdminUser { user, session }) } @@ -292,7 +316,10 @@ 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 { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { Ok(MaybeUser(current_user(state, &parts.headers).await)) } } @@ -333,7 +360,10 @@ pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> { } 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?; + 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(()) @@ -346,8 +376,18 @@ mod tests { #[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_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/crypto.rs b/server/src/crypto.rs index 3b03df3..5bbbcbd 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -88,11 +88,22 @@ pub fn encrypt(text: &str) -> String { 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: &[] }) + .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)) + format!( + "{}:{}:{}", + hex::encode(iv), + hex::encode(tag), + hex::encode(ciphertext) + ) } /// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en @@ -115,7 +126,13 @@ pub fn decrypt(value: &str) -> String { 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: &[] }) + .decrypt( + Nonce::::from_slice(&iv), + Payload { + msg: &data, + aad: &[], + }, + ) .ok()?; String::from_utf8(plain).ok() }; diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index b40a61a..1d1da85 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -18,7 +18,8 @@ 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 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()?; @@ -193,13 +194,21 @@ impl Patch { } /// Exécute `UPDATE table SET … WHERE id = $id RETURNING *`. -async fn update_returning(pool: &PgPool, table: &str, id: &str, patch: &Patch) -> Result, sqlx::Error> +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; + 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); @@ -289,7 +298,12 @@ pub struct NewScheduledPost { } impl NewScheduledPost { - pub fn new(post_id: impl Into, page_id: impl Into, post_type: PostType, scheduled_at: Ts) -> Self { + 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(), @@ -351,7 +365,10 @@ impl Db { // ------------------------------------------------------------------ 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 + 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> { @@ -362,17 +379,26 @@ impl Db { } pub async fn get_all_users(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM users").fetch_all(&self.pool).await + 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 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> { @@ -380,7 +406,10 @@ impl Db { } 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?; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -421,15 +450,18 @@ impl Db { // ------------------------------------------------------------------ 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?; + 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?; + let pages: Vec = sqlx::query_as("SELECT * FROM social_pages") + .fetch_all(&self.pool) + .await?; Ok(pages.into_iter().map(decrypt_page).collect()) } @@ -471,16 +503,36 @@ impl Db { /// 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> { + 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?; + .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?; + sqlx::query("DELETE FROM social_pages WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -488,7 +540,12 @@ impl Db { // Médias // ------------------------------------------------------------------ - pub async fn get_media(&self, user_id: &str, limit: Option, offset: Option) -> Result, sqlx::Error> { + 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", ) @@ -503,7 +560,10 @@ impl Db { 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 + 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 { @@ -514,7 +574,10 @@ impl Db { } 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 + 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 { @@ -545,7 +608,10 @@ impl Db { } 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?; + sqlx::query("DELETE FROM media WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -568,19 +634,32 @@ impl Db { } 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 + 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> { + 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(); + 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))) } @@ -617,7 +696,9 @@ impl Db { progress: i32, error: Option<&str>, ) -> Result, sqlx::Error> { - let mut patch = Patch::new().set("generation_status", status).set("generation_progress", progress); + let mut patch = Patch::new() + .set("generation_status", status) + .set("generation_progress", progress); if let Some(error) = error { patch = patch.set("generation_error", error); } @@ -625,31 +706,51 @@ impl Db { } 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?; + 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(); + 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> { + 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)") + sqlx::query("DELETE FROM post_media WHERE post_id = $1") .bind(post_id) - .bind(media_id) - .bind(order) .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 } @@ -663,7 +764,13 @@ impl Db { start: Option, end: Option, ) -> Result, sqlx::Error> { - self.query_scheduled_posts("p.user_id = $1", ScopeBind::Text(user_id.to_string()), start, end).await + 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( @@ -675,7 +782,13 @@ impl Db { 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 + self.query_scheduled_posts( + "sp.page_id = ANY($1)", + ScopeBind::List(page_ids.to_vec()), + start, + end, + ) + .await } async fn query_scheduled_posts( @@ -733,23 +846,36 @@ impl Db { }), None => None, }; - out.push(ScheduledPostWithDetails { scheduled, post, page }); + 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 + 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> { + 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 { + 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 *"#, @@ -764,25 +890,36 @@ impl Db { .await } - pub async fn update_scheduled_post(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + 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?; + 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 + 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> { + 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)", @@ -817,15 +954,20 @@ impl Db { .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(); + 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)); + map.insert( + "generationStep".into(), + step.map(Value::String).unwrap_or(Value::Null), + ); } value }) @@ -847,7 +989,10 @@ impl Db { // Générations IA // ------------------------------------------------------------------ - pub async fn get_ai_generations(&self, user_id: &str) -> Result, sqlx::Error> { + 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) @@ -875,7 +1020,9 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_cloudinary_config(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1").fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1") + .fetch_optional(&self.pool) + .await } pub async fn create_cloudinary_config( @@ -908,9 +1055,14 @@ impl Db { .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"))?; + let updated = update_returning::( + &self.pool, + "cloudinary_config", + &existing.id, + &patch, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; Ok(updated) } @@ -918,7 +1070,10 @@ impl Db { // OpenRouter // ------------------------------------------------------------------ - pub async fn get_openrouter_config(&self, user_id: &str) -> Result, sqlx::Error> { + 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) @@ -926,9 +1081,11 @@ impl Db { } 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 + 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. @@ -954,7 +1111,11 @@ impl Db { .await } - pub async fn update_openrouter_config(&self, user_id: &str, patch: Patch) -> Result, sqlx::Error> { + 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); @@ -968,29 +1129,45 @@ impl Db { // Permissions des utilisateurs sur les pages // ------------------------------------------------------------------ - pub async fn get_user_page_permissions(&self, user_id: &str) -> Result, sqlx::Error> { + 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> { + 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 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> { + 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) @@ -1008,7 +1185,10 @@ impl Db { } /// Pages attribuées à un utilisateur (jetons déchiffrés). - pub async fn get_user_accessible_pages(&self, user_id: &str) -> Result, sqlx::Error> { + 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 @@ -1033,14 +1213,20 @@ impl Db { // Musique // ------------------------------------------------------------------ - pub async fn get_music_favorites(&self, user_id: &str) -> Result, sqlx::Error> { + 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 { + 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) @@ -1060,7 +1246,11 @@ impl Db { .await } - pub async fn remove_music_favorite(&self, user_id: &str, track_id: &str) -> Result<(), sqlx::Error> { + 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) @@ -1069,13 +1259,18 @@ impl Db { 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?; + 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()) } @@ -1084,11 +1279,16 @@ impl Db { // ------------------------------------------------------------------ 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 + 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 + sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } pub async fn create_audio_track( @@ -1112,7 +1312,10 @@ impl Db { } 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?; + sqlx::query("DELETE FROM audio_tracks WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -1121,14 +1324,18 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_app_config(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM app_config LIMIT 1").fetch_optional(&self.pool).await + 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? { + if let Some(updated) = + update_returning(&self.pool, "app_config", &existing.id, &patch).await? + { return Ok(updated); } } @@ -1147,7 +1354,9 @@ impl Db { 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?; + 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 @@ -1155,9 +1364,15 @@ impl Db { } /// 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 { + 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?; + 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) => { @@ -1174,14 +1389,17 @@ impl Db { .ok_or_else(|| anyhow::anyhow!("configuration Facebook introuvable"))? } None => { - let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret) else { + 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? + 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); @@ -1189,8 +1407,9 @@ impl Db { } 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?; + 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 @@ -1198,9 +1417,14 @@ impl Db { } /// 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?; + 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) => { @@ -1217,7 +1441,8 @@ impl Db { .ok_or_else(|| anyhow::anyhow!("configuration TikTok introuvable"))? } None => { - let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret) else { + 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 *") diff --git a/server/src/db/models.rs b/server/src/db/models.rs index 1a687ff..e4a491b 100644 --- a/server/src/db/models.rs +++ b/server/src/db/models.rs @@ -104,7 +104,10 @@ impl SocialPage { 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()), + _ => 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)); diff --git a/server/src/error.rs b/server/src/error.rs index 1f5a5c4..539b667 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -16,7 +16,10 @@ 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() }) } + Self { + status, + body: json!({ "error": message.into() }), + } } /// Corps JSON libre (ex. `{ success: false, error }`). diff --git a/server/src/http.rs b/server/src/http.rs index c01c61e..d2333fc 100644 --- a/server/src/http.rs +++ b/server/src/http.rs @@ -30,7 +30,11 @@ pub struct RateDecision { impl RateLimiter { pub fn new(max: u32, window: Duration) -> Self { - Self { max, window, hits: Mutex::new(HashMap::new()) } + Self { + max, + window, + hits: Mutex::new(HashMap::new()), + } } pub fn check(&self, ip: IpAddr) -> RateDecision { @@ -59,7 +63,7 @@ impl RateLimiter { /// 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 Some(ip) = xff.split(',').map(str::trim).rfind(|s| !s.is_empty()) { if let Ok(ip) = ip.parse() { return ip; } @@ -73,7 +77,10 @@ pub async fn rate_limit(State(state): State, request: Request, next: N if !path.starts_with("/api/") { return next.run(request).await; } - let peer = request.extensions().get::>().map(|c| c.0); + let peer = request + .extensions() + .get::>() + .map(|c| c.0); let ip = client_ip(request.headers(), peer); let decision = state.global_limiter.check(ip); @@ -83,7 +90,10 @@ pub async fn rate_limit(State(state): State, request: Request, next: N 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"); + return too_many( + &auth, + "Trop de tentatives de connexion, réessayez dans 15 minutes", + ); } } @@ -96,7 +106,11 @@ pub async fn rate_limit(State(state): State, request: Request, next: N } fn too_many(decision: &RateDecision, message: &str) -> Response { - let mut response = (StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": message }))).into_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)); @@ -132,10 +146,20 @@ pub async fn security_headers(request: Request, next: Next) -> Response { 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( + "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( + "strict-transport-security", + "max-age=31536000; includeSubDomains", + ); set("x-content-type-options", "nosniff"); set("x-dns-prefetch-control", "off"); set("x-download-options", "noopen"); @@ -157,7 +181,11 @@ pub async fn log_requests(request: Request, next: Next) -> Response { 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()); + tracing::info!( + "{method} {path} {} in {}ms", + status.as_u16(), + started.elapsed().as_millis() + ); } response } diff --git a/server/src/migrate.rs b/server/src/migrate.rs index ac128b9..21de9c9 100644 --- a/server/src/migrate.rs +++ b/server/src/migrate.rs @@ -255,23 +255,74 @@ const COLUMNS: &[&str] = &[ /// 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")"#), + ( + "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) { @@ -281,7 +332,11 @@ pub async fn run(pool: &PgPool) { 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(" "); + let head: String = statement + .split_whitespace() + .take(6) + .collect::>() + .join(" "); tracing::warn!("[Migration] Échec de « {head} … » : {error}"); } } diff --git a/server/src/public_url.rs b/server/src/public_url.rs index a073959..727147b 100644 --- a/server/src/public_url.rs +++ b/server/src/public_url.rs @@ -25,9 +25,11 @@ pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) -> return url.clone(); } if let Some(headers) = headers { - let host = first_header_value(headers, "x-forwarded-host").or_else(|| first_header_value(headers, "host")); + 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()); + let protocol = + first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into()); return format!("{protocol}://{host}"); } } diff --git a/server/src/routes/auth.rs b/server/src/routes/auth.rs index d67b887..77df863 100644 --- a/server/src/routes/auth.rs +++ b/server/src/routes/auth.rs @@ -17,7 +17,10 @@ pub fn router() -> Router { .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)) + .route( + "/api/auth/default-password-status", + get(default_password_status), + ) } async fn login( @@ -28,8 +31,14 @@ async fn login( 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 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")); }; @@ -53,14 +62,19 @@ async fn login( Ok(auth::with_cookie(auth::user_json(&user), cookie)) } -async fn logout(axum::extract::State(state): axum::extract::State, headers: HeaderMap) -> ApiResult { +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()); + response + .headers_mut() + .append(SET_COOKIE, auth::clear_session_cookie()); Ok(response) } @@ -72,7 +86,9 @@ async fn session(MaybeUser(user): MaybeUser) -> Response { } /// Le mot de passe de `admin` est-il toujours « admin » ? -async fn default_password_status(axum::extract::State(state): axum::extract::State) -> ApiResult> { +async fn default_password_status( + axum::extract::State(state): axum::extract::State, +) -> ApiResult> { let admin = state .db .get_user_by_username("admin") diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 584bdea..6d718a4 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,8 +1,8 @@ //! Assemblage des routes HTTP. +mod analytics; mod auth; mod core; -mod analytics; mod external; mod facebook; mod legal; @@ -35,7 +35,11 @@ pub fn router(state: AppState) -> Router { .merge(legal::router()); let config = &state.config; - for dir in [&config.uploads_dir, &config.audio_upload_dir, &config.temp_upload_dir] { + 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"] { @@ -51,7 +55,10 @@ pub fn router(state: AppState) -> Router { 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_with_state( + state.clone(), + http::rate_limit, + )) .layer(middleware::from_fn(http::log_requests)) .layer(middleware::from_fn(http::security_headers)) .with_state(state) @@ -59,7 +66,10 @@ pub fn router(state: AppState) -> Router { /// Sonde de santé (Docker) : le serveur répond et la base est joignable. async fn healthz(State(state): State) -> Response { - match sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db.pool).await { + match sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.db.pool) + .await + { Ok(_) => (StatusCode::OK, "ok").into_response(), Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable").into_response(), } @@ -70,16 +80,27 @@ async fn fallback(State(state): State, headers: HeaderMap, request: Re 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() + ( + StatusCode::UNAUTHORIZED, + axum::Json(json!({ "error": "Non authentifié" })), + ) + .into_response() } else { - (StatusCode::NOT_FOUND, axum::Json(json!({ "error": "Route introuvable" }))).into_response() + ( + 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('/')); + let candidate = state + .config + .public_root_dir + .join(path.trim_start_matches('/')); if candidate.is_file() { return serve(ServeFile::new(candidate), request).await; } diff --git a/server/src/services/audio.rs b/server/src/services/audio.rs index 7bb21c8..2ace75b 100644 --- a/server/src/services/audio.rs +++ b/server/src/services/audio.rs @@ -57,7 +57,14 @@ pub async fn fix_audio_track_encoding(state: &AppState) -> usize { /// 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"]) + .args([ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + ]) .arg(path) .output() .await; diff --git a/server/src/services/ffmpeg.rs b/server/src/services/ffmpeg.rs index b771b70..0fb6133 100644 --- a/server/src/services/ffmpeg.rs +++ b/server/src/services/ffmpeg.rs @@ -39,6 +39,9 @@ pub async fn health_check(state: &AppState) -> bool { } /// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service. -pub async fn list_voices(state: &AppState, qwen: Option) -> anyhow::Result { +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_store.rs b/server/src/services/media_store.rs index 4bfb7f2..8eb38bc 100644 --- a/server/src/services/media_store.rs +++ b/server/src/services/media_store.rs @@ -17,7 +17,11 @@ 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"); + let base = state + .config + .app_url + .as_deref() + .unwrap_or("http://localhost:5555"); format!("{}{url}", base.trim_end_matches('/')) } @@ -40,8 +44,13 @@ pub fn resolve_internal_url(state: &AppState, url: &str) -> String { 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()) { + 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/") { @@ -78,7 +87,13 @@ fn safe_name(file_name: &str) -> String { .and_then(|n| n.to_str()) .unwrap_or("file"); base.chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '-' + } + }) .collect() } @@ -111,7 +126,11 @@ pub enum MediaSource<'a> { Bytes(&'a [u8]), } -pub async fn upload_media(state: &AppState, source: MediaSource<'_>, file_name: &str) -> anyhow::Result { +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() { @@ -148,14 +167,26 @@ pub async fn delete_media(state: &AppState, public_id: &str) -> anyhow::Result<( } /// 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)); +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)> { +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); @@ -163,7 +194,11 @@ pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) -> } /// Logo de l'entreprise ; renvoie (clé, URL). -pub async fn upload_logo(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> { +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()) diff --git a/server/src/services/thumbnail.rs b/server/src/services/thumbnail.rs index e6ab732..59382c8 100644 --- a/server/src/services/thumbnail.rs +++ b/server/src/services/thumbnail.rs @@ -39,7 +39,10 @@ pub async fn generate_video_thumbnail(video: &Path, output: &Path, seek_time: f6 /// 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 { +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; @@ -72,13 +75,17 @@ pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_ 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 + 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 { diff --git a/server/src/state.rs b/server/src/state.rs index aafd0f3..11d2e49 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -49,7 +49,10 @@ impl AppState { .build()?; let ffmpeg = FfmpegSettings { - api_url: config.ffmpeg_api_url.as_ref().map(|u| u.trim_end_matches('/').to_string()), + 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() { diff --git a/server/src/time.rs b/server/src/time.rs index bbb67c6..43498e9 100644 --- a/server/src/time.rs +++ b/server/src/time.rs @@ -24,15 +24,15 @@ impl Ts { Ts(dt.naive_utc()) } - pub fn utc(&self) -> DateTime { + pub fn utc(self) -> DateTime { self.0.and_utc() } - pub fn to_iso(&self) -> String { + pub fn to_iso(self) -> String { self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() } - pub fn timestamp_millis(&self) -> i64 { + pub fn timestamp_millis(self) -> i64 { self.0.and_utc().timestamp_millis() } @@ -40,7 +40,7 @@ impl Ts { DateTime::::from_timestamp_millis(ms).map(Ts::from_utc) } - pub fn add(&self, d: Duration) -> Self { + pub fn add(self, d: Duration) -> Self { Ts(self.0 + d) } @@ -68,9 +68,8 @@ 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::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)) @@ -102,7 +101,11 @@ pub fn parse_js_date(value: &str) -> Option { 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"] { + 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))); } @@ -149,8 +152,14 @@ mod tests { #[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_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 index dd86dc3..1ec696f 100644 --- a/server/src/upload.rs +++ b/server/src/upload.rs @@ -35,7 +35,13 @@ pub struct UploadRules<'a> { pub fn disk_name(original: &str) -> String { let safe: String = original .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }) + .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); @@ -103,7 +109,10 @@ pub async fn receive_files( { size += chunk.len() as u64; if size > rules.max_file_size { - return Err(AppError::new(axum::http::StatusCode::PAYLOAD_TOO_LARGE, "File too large")); + return Err(AppError::new( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "File too large", + )); } out.write_all(&chunk) .await From d2293f846ec63c481028d619ab9b6ca28c73a543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:36:20 +0000 Subject: [PATCH 05/10] Serveur Rust : Reels (client FFmpeg, file de rendus, Remotion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage de services/ffmpeg.ts, services/reels/*, ttsSync.ts et routes/reels.ts : même file persistante reel_jobs (FOR UPDATE SKIP LOCKED, tentatives, battement de cœur, synchronisation des colonnes generation_* des posts), mêmes validations (messages zod reproduits, paramètres stockés en JSON camelCase compatibles avec les jobs de l'ancien serveur), mêmes réponses JSON. Le rendu Remotion passe par un nouveau service Node (remotion-service/) : POST /renders, suivi de la progression, téléchargement du MP4 en flux, mêmes réglages d'encodage. Sans REMOTION_RENDERER_URL, repli sur le rendu FFmpeg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- remotion-service/.gitignore | 3 + remotion-service/Dockerfile | 66 + remotion-service/package-lock.json | 2931 +++++++++++++++++++++++++ remotion-service/package.json | 24 + remotion-service/server.mjs | 349 +++ server/src/routes/reels.rs | 466 +++- server/src/services/ffmpeg.rs | 621 +++++- server/src/services/reels/assets.rs | 103 + server/src/services/reels/mod.rs | 39 +- server/src/services/reels/params.rs | 591 +++++ server/src/services/reels/pipeline.rs | 256 +++ server/src/services/reels/publish.rs | 350 +++ server/src/services/reels/queue.rs | 391 ++++ server/src/services/reels/remotion.rs | 346 +++ server/src/services/reels/tts_sync.rs | 109 + 15 files changed, 6628 insertions(+), 17 deletions(-) create mode 100644 remotion-service/.gitignore create mode 100644 remotion-service/Dockerfile create mode 100644 remotion-service/package-lock.json create mode 100644 remotion-service/package.json create mode 100644 remotion-service/server.mjs create mode 100644 server/src/services/reels/assets.rs create mode 100644 server/src/services/reels/params.rs create mode 100644 server/src/services/reels/pipeline.rs create mode 100644 server/src/services/reels/publish.rs create mode 100644 server/src/services/reels/queue.rs create mode 100644 server/src/services/reels/remotion.rs create mode 100644 server/src/services/reels/tts_sync.rs diff --git a/remotion-service/.gitignore b/remotion-service/.gitignore new file mode 100644 index 0000000..8932514 --- /dev/null +++ b/remotion-service/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Bundle construit par `node server.mjs --prepare` +bundle diff --git a/remotion-service/Dockerfile b/remotion-service/Dockerfile new file mode 100644 index 0000000..dbb40b5 --- /dev/null +++ b/remotion-service/Dockerfile @@ -0,0 +1,66 @@ +# Service de rendu Remotion des Reels (sous-titres animés, logo, effet de fin). +# Contexte de build : la racine du dépôt (les compositions vivent dans +# client/src/remotion et importent shared/captions.ts). +# docker build -f remotion-service/Dockerfile . + +# Debian slim — glibc requis par le Chrome headless shell de Remotion +FROM node:20-slim + +# Dépendances du Chrome headless shell et polices de secours +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-dejavu-core \ + fontconfig \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + libxshmfence1 \ + libxtst6 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Dépendances (versions épinglées, identiques au package.json racine) +COPY remotion-service/package.json remotion-service/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +# Compositions et module partagé qu'elles importent (@shared/captions). +# La police Montserrat vient du paquet @fontsource/montserrat. +COPY client/src/remotion ./client/src/remotion +COPY shared/captions.ts ./shared/captions.ts +COPY remotion-service/server.mjs ./server.mjs + +# Pré-bundle des compositions et téléchargement du Chrome headless shell +# (équivalent de `npx remotion browser ensure`) : aucun accès réseau au +# premier rendu. +RUN node server.mjs --prepare && rm -rf /tmp/remotion-* + +ENV NODE_ENV=production \ + PORT=8100 \ + OUTPUT_DIR=/tmp/remotion-renders + +# Utilisateur non privilégié ; seuls /tmp et le cache Chrome sont utilisés +RUN chown -R node:node /app +USER node + +EXPOSE 8100 + +CMD ["node", "server.mjs"] diff --git a/remotion-service/package-lock.json b/remotion-service/package-lock.json new file mode 100644 index 0000000..c03d271 --- /dev/null +++ b/remotion-service/package-lock.json @@ -0,0 +1,2931 @@ +{ + "name": "socialflow-remotion-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "socialflow-remotion-service", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@fontsource/montserrat": "5.3.0", + "@remotion/bundler": "4.0.438", + "@remotion/renderer": "4.0.438", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.438" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", + "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.39.2.tgz", + "integrity": "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.39.2.tgz", + "integrity": "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.39.2.tgz", + "integrity": "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.438.tgz", + "integrity": "sha512-2CVJhgQvdYwh+ioYMZ5xIA+22NIX1/sARTOqDd9nRzxrueTuHN3/U0fhygdwz6TsoVqM9EuDsP1RPSeUbXURpw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.438", + "@remotion/studio": "4.0.438", + "@remotion/studio-shared": "4.0.438", + "@rspack/core": "1.7.6", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "5.2.7", + "esbuild": "0.25.0", + "react-refresh": "0.18.0", + "remotion": "4.0.438", + "source-map": "0.7.3", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.438.tgz", + "integrity": "sha512-iRw31eVGczU9gdvJtdNttX09dy9RyZfO/yI+Vtl6LNMWemKrLLuup2gDbU7i7QVw2a6vUH+cv/2eLUtbKdvQJw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.438.tgz", + "integrity": "sha512-BOupRCn0yYGD1SA5hHcCBoiDb2pPQEkagGFhhefAsJM9mK5blhkRCCCSRMm9kvUCEIUAAEXKZ5NpXgtUFsiqsw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.438.tgz", + "integrity": "sha512-7S1bDtERk/sQVp6ETKD+Ici0Euq7jo1qaYQQz4eqKqik3S/pPGnOnRdPiArA4W9VR3voCj3asIsuk+A0hDu2hw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.438.tgz", + "integrity": "sha512-n6U9rS3ipTd3pLcKF4WxGbdD+Hte7YgkTpDFfX6GRFZTz41d8q40xMKlgNiGmPPOfLyKtY+Af5K0RKAK6wvIkQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.438.tgz", + "integrity": "sha512-IhEOAYphOLETvq8dWllii201YOteukoS2hCwj4jgv39A5slIbyBxUS6TfuFzel2PghwcJDxNLKzbbJAJ0P89ng==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.438.tgz", + "integrity": "sha512-qOmPquSwDTcP8JkdjbLdnbjZv+5BLRSo3BOqIGIcA5MBPOs2fZGM4MAFtO1Cd7IP6M2rzwawcVzplHbWUjn9FA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.438.tgz", + "integrity": "sha512-uso+PNYVQax2wZekkDsfPQrrRT35wySGf6sC2CbQXiwBeHAtsksZZlPpM/N00Rg227ew9jVit7pevdxwt7AD6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.438.tgz", + "integrity": "sha512-JYg8ebbVkDJDnKt0BZV7OEptAysKkIWTdRPVGdwPeNw6CMcCooPPDuR7gGHjPtm0Odn/A3EKUopCX3kTvyWTPQ==", + "license": "MIT" + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.438.tgz", + "integrity": "sha512-BDqu70nW0BAlQhSn0wYUo/r47MphvYJ0pHz4/UW3PRkiYn3iOIIF9/PO4MM2nomBxirMzGoRfxp0aUbTimTyXg==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.438.tgz", + "integrity": "sha512-ncXhPkpO2RTEYFhtHmeWgQiXJQ3JLFVx3q7h4N5DtI8CXn0A3lOuzExkUaVYM6Gr8dTwoStLbYnt+WX9Ev6Lxg==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.39.2", + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.438.tgz", + "integrity": "sha512-2OT8r0arsjxUEVEa5dNgain7ChbDG0THZglxh1Bo4U8nTSMnuX+1yyF0MVOlmT56yaESfoiGbZvnnpB6e7Ltew==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.438.tgz", + "integrity": "sha512-gTQZ3o8bN7oZIj4aN7hRQ+q5ycOZ1J4TEcGCOXtsLscBbGqiq/dUedDjDWrtdd+VUrPXP3V7bhn7+yWI5/1sHA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.438", + "@remotion/streaming": "4.0.438", + "execa": "5.1.1", + "extract-zip": "2.0.1", + "remotion": "4.0.438", + "source-map": "^0.8.0-beta.0", + "ws": "8.17.1" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.438", + "@remotion/compositor-darwin-x64": "4.0.438", + "@remotion/compositor-linux-arm64-gnu": "4.0.438", + "@remotion/compositor-linux-arm64-musl": "4.0.438", + "@remotion/compositor-linux-x64-gnu": "4.0.438", + "@remotion/compositor-linux-x64-musl": "4.0.438", + "@remotion/compositor-win32-x64-msvc": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer/node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.438.tgz", + "integrity": "sha512-D0mTdQzpmP9RilnxHCvkTVFuE2FQ0SUw2DzAJQ3KBkBC4tBL6mVUoT289NsqjhNxTLmvZfi4LN85U8kpz03CpQ==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.438.tgz", + "integrity": "sha512-qGsHigArmTFGrdKKQyWM5WiHwp7fN00W6L1hHB3PXiSyCTGeyHQ5EMgdVlFQL59QXidRMINGKk1pMSR2vzWo8w==", + "license": "MIT", + "dependencies": { + "@remotion/media-utils": "4.0.438", + "@remotion/player": "4.0.438", + "@remotion/renderer": "4.0.438", + "@remotion/studio-shared": "4.0.438", + "@remotion/web-renderer": "4.0.438", + "@remotion/zod-types": "4.0.438", + "mediabunny": "1.39.2", + "memfs": "3.4.3", + "open": "^8.4.2", + "remotion": "4.0.438", + "semver": "7.5.3", + "source-map": "0.7.3", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.438.tgz", + "integrity": "sha512-c/92eFDRCDTltZKEna8J9MWaCpONgfDThzhpp+z8geg0d8HeeYFenvKd1NxH9ihuJg09HZLXTV5u9hUpnUj1wA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.438" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.438.tgz", + "integrity": "sha512-cOGtgqRiD+q77Vj65gy/fo+GK1wA3G5ft2xYLPJZzddmrhhrhibyFglljY2Vu7UaXYIUWZTkGoXwdKchC09UZA==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.39.2", + "@mediabunny/flac-encoder": "1.39.2", + "@mediabunny/mp3-encoder": "1.39.2", + "@remotion/licensing": "4.0.438", + "mediabunny": "1.39.2", + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.438.tgz", + "integrity": "sha512-Rif8RJO5JpACl8EPjgvufAeg0nujI5eBgDdFKmiysEtjGq2E6G1nwPcBWSxwQLY8L++9FzY1hidFl7DBjWF9yA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.438" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.6.tgz", + "integrity": "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.6", + "@rspack/binding-darwin-x64": "1.7.6", + "@rspack/binding-linux-arm64-gnu": "1.7.6", + "@rspack/binding-linux-arm64-musl": "1.7.6", + "@rspack/binding-linux-x64-gnu": "1.7.6", + "@rspack/binding-linux-x64-musl": "1.7.6", + "@rspack/binding-wasm32-wasi": "1.7.6", + "@rspack/binding-win32-arm64-msvc": "1.7.6", + "@rspack/binding-win32-ia32-msvc": "1.7.6", + "@rspack/binding-win32-x64-msvc": "1.7.6" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.6.tgz", + "integrity": "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.6.tgz", + "integrity": "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.6.tgz", + "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.6", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mediabunny": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz", + "integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==", + "license": "MPL-2.0", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.438.tgz", + "integrity": "sha512-keG2GxHh81UFV0zFrwRh+yngXHn6C3z/Nohc+ru49M1TlIcaThG/hD2amJNH9otYzpYJxVjNF1t/i1hqcI8OGw==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/remotion-service/package.json b/remotion-service/package.json new file mode 100644 index 0000000..6554871 --- /dev/null +++ b/remotion-service/package.json @@ -0,0 +1,24 @@ +{ + "name": "socialflow-remotion-service", + "version": "1.0.0", + "private": true, + "description": "Service de rendu Remotion des Reels SocialFlow (sous-titres animés, logo, effet de fin)", + "type": "module", + "license": "MIT", + "main": "server.mjs", + "scripts": { + "start": "node server.mjs", + "prepare-render": "node server.mjs --prepare" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@fontsource/montserrat": "5.3.0", + "@remotion/bundler": "4.0.438", + "@remotion/renderer": "4.0.438", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.438" + } +} diff --git a/remotion-service/server.mjs b/remotion-service/server.mjs new file mode 100644 index 0000000..8122b8b --- /dev/null +++ b/remotion-service/server.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * Service de rendu Remotion des Reels SocialFlow. + * + * Le serveur principal (Rust) ne peut pas exécuter Remotion (React + + * Chromium) : il lui confie la composition « ReelVideo » (sous-titres animés, + * logo, effet de fin) avec la vidéo et le son déjà préparés par le service + * FFmpeg, puis récupère le MP4. + * + * API (en-tête X-API-Key obligatoire, variable API_KEY) : + * POST /renders { compositionId, inputProps } → { id } + * GET /renders/:id → { status, progress (0..1), error? } + * GET /renders/:id/file → le MP4 + * DELETE /renders/:id → annule le rendu et supprime le fichier + * GET /health → { status: "ok" } + * + * `node server.mjs --prepare` construit le bundle et télécharge le Chrome + * headless shell (étape de build Docker), puis s'arrête. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundle } from "@remotion/bundler"; +import { ensureBrowser, makeCancelSignal, renderMedia, selectComposition } from "@remotion/renderer"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** Racine contenant client/src/remotion et shared/ : l'image Docker, sinon le dépôt. */ +const SOURCE_ROOT = + process.env.REMOTION_SOURCE_ROOT || + (fs.existsSync(path.join(HERE, "client/src/remotion")) ? HERE : path.resolve(HERE, "..")); +const ENTRY_POINT = path.join(SOURCE_ROOT, "client/src/remotion/index.ts"); +const BUNDLE_DIR = process.env.BUNDLE_DIR || path.join(HERE, "bundle"); +const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(os.tmpdir(), "remotion-renders"); + +const PORT = Number(process.env.PORT) || 8100; +const API_KEY = process.env.API_KEY || ""; +/** Rendus simultanés (chacun occupe déjà plusieurs onglets Chromium). */ +const MAX_PARALLEL_RENDERS = Math.max(1, Number(process.env.MAX_PARALLEL_RENDERS) || 1); +/** Les props contiennent le logo en data URL. */ +const MAX_BODY_BYTES = 32 * 1024 * 1024; +/** Rendus terminés conservés au plus une heure s'ils ne sont pas supprimés. */ +const RESULT_TTL_MS = 60 * 60_000; + +const log = (...args) => console.log(new Date().toISOString(), ...args); +const warn = (...args) => console.warn(new Date().toISOString(), ...args); + +// --------------------------------------------------------------------------- +// Bundle des compositions +// --------------------------------------------------------------------------- + +function bundleOptions(outDir) { + return { + entryPoint: ENTRY_POINT, + outDir, + // Mêmes alias que Vite : les compositions importent @shared/* + webpackOverride: (config) => ({ + ...config, + resolve: { + ...config.resolve, + alias: { + ...(config.resolve?.alias ?? {}), + "@shared": path.join(SOURCE_ROOT, "shared"), + }, + // Dépendances (react, remotion, polices) installées à côté de ce service + modules: [...(config.resolve?.modules ?? ["node_modules"]), path.join(HERE, "node_modules")], + }, + }), + }; +} + +let bundlePromise = null; + +/** Bundle construit au build Docker, sinon construit une fois par processus. */ +function getBundle() { + if (fs.existsSync(path.join(BUNDLE_DIR, "index.html"))) return Promise.resolve(BUNDLE_DIR); + if (!bundlePromise) { + log("📦 [Remotion] Bundle de", ENTRY_POINT); + bundlePromise = bundle(bundleOptions(null)).catch((error) => { + bundlePromise = null; // réessayer au prochain rendu + throw error; + }); + } + return bundlePromise; +} + +/** Onglets Chromium en parallèle : par défaut la moitié des cœurs, 1 au minimum. */ +function renderConcurrency() { + const configured = Number(process.env.RENDER_CONCURRENCY); + if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); + return Math.max(1, Math.floor(os.cpus().length / 2)); +} + +// --------------------------------------------------------------------------- +// Rendus +// --------------------------------------------------------------------------- + +/** @type {Map void}>} */ +const renders = new Map(); +const waiting = []; +let running = 0; + +function enqueue(compositionId, inputProps) { + const id = crypto.randomUUID(); + const render = { + id, + compositionId, + inputProps, + status: "pending", + progress: 0, + output: path.join(OUTPUT_DIR, `${id}.mp4`), + }; + renders.set(id, render); + waiting.push(render); + pump(); + return render; +} + +function pump() { + while (running < MAX_PARALLEL_RENDERS && waiting.length) { + const render = waiting.shift(); + if (!renders.has(render.id)) continue; // supprimé avant son tour + running++; + run(render).finally(() => { + running--; + pump(); + }); + } +} + +async function run(render) { + render.status = "rendering"; + const startedAt = Date.now(); + const { cancelSignal, cancel } = makeCancelSignal(); + render.cancel = cancel; + try { + const serveUrl = await getBundle(); + const composition = await selectComposition({ + serveUrl, + id: render.compositionId, + inputProps: render.inputProps, + }); + await renderMedia({ + composition, + serveUrl, + inputProps: render.inputProps, + outputLocation: render.output, + codec: "h264", + // Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok + crf: 18, + x264Preset: "medium", + pixelFormat: "yuv420p", + colorSpace: "bt709", + audioCodec: "aac", + audioBitrate: "192k", + concurrency: renderConcurrency(), + cancelSignal, + onProgress: ({ progress }) => { + render.progress = progress; + }, + // Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu + timeoutInMilliseconds: 60_000, + onBrowserLog: (entry) => { + if (entry.type === "error") warn("⚠️ [Remotion] Navigateur :", entry.text.slice(0, 300)); + }, + }); + render.status = "done"; + render.progress = 1; + log(`🎞️ [Remotion] ${render.compositionId} ${render.id} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`); + } catch (error) { + render.status = "failed"; + render.error = error instanceof Error ? error.message : String(error); + warn(`❌ [Remotion] Rendu ${render.id} en échec :`, render.error); + fs.promises.rm(render.output, { force: true }).catch(() => {}); + } finally { + render.cancel = undefined; + render.finishedAt = Date.now(); + // Les props (logo en data URL) ne servent plus + render.inputProps = null; + } +} + +function remove(id) { + const render = renders.get(id); + if (!render) return; + renders.delete(id); + render.cancel?.(); + fs.promises.rm(render.output, { force: true }).catch(() => {}); +} + +// Purge des rendus oubliés +setInterval(() => { + const now = Date.now(); + for (const render of renders.values()) { + if (render.finishedAt && now - render.finishedAt > RESULT_TTL_MS) remove(render.id); + } +}, 10 * 60_000).unref(); + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + +function send(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(data) }); + res.end(data); +} + +function authorized(req) { + const given = Buffer.from(String(req.headers["x-api-key"] ?? "")); + const expected = Buffer.from(API_KEY); + return given.length === expected.length && crypto.timingSafeEqual(given, expected); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(Object.assign(new Error("Corps de requête trop volumineux"), { status: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}); + } catch { + reject(Object.assign(new Error("JSON invalide"), { status: 400 })); + } + }); + req.on("error", reject); + }); +} + +async function handle(req, res) { + const url = new URL(req.url ?? "/", "http://localhost"); + const parts = url.pathname.split("/").filter(Boolean); + + if (!authorized(req)) return send(res, 401, { error: "Invalid API Key" }); + + if (req.method === "GET" && url.pathname === "/health") { + return send(res, 200, { status: "ok", renders: renders.size, running }); + } + + if (parts[0] !== "renders") return send(res, 404, { error: "Route introuvable" }); + + if (parts.length === 1 && req.method === "POST") { + const body = await readJson(req); + if (typeof body?.compositionId !== "string" || !body.compositionId) { + return send(res, 400, { error: "compositionId requis" }); + } + if (body.inputProps == null || typeof body.inputProps !== "object" || Array.isArray(body.inputProps)) { + return send(res, 400, { error: "inputProps doit être un objet" }); + } + const render = enqueue(body.compositionId, body.inputProps); + log(`🎬 [Remotion] Rendu ${render.id} (${render.compositionId}) en file`); + return send(res, 202, { id: render.id }); + } + + const render = parts[1] ? renders.get(parts[1]) : undefined; + + if (parts.length === 2 && req.method === "DELETE") { + remove(parts[1]); + return send(res, 200, { success: true }); + } + if (!render) return send(res, 404, { error: "Rendu introuvable ou expiré" }); + + if (parts.length === 2 && req.method === "GET") { + return send(res, 200, { + status: render.status, + progress: render.progress, + ...(render.error ? { error: render.error } : {}), + }); + } + + if (parts.length === 3 && parts[2] === "file" && req.method === "GET") { + if (render.status !== "done") return send(res, 409, { error: "Rendu non terminé" }); + let stat; + try { + stat = await fs.promises.stat(render.output); + } catch { + return send(res, 404, { error: "Fichier introuvable ou expiré" }); + } + res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": stat.size }); + fs.createReadStream(render.output) + .on("error", () => res.destroy()) + .pipe(res); + return; + } + + return send(res, 405, { error: "Méthode non autorisée" }); +} + +// --------------------------------------------------------------------------- +// Démarrage +// --------------------------------------------------------------------------- + +async function prepare() { + log("📦 [Remotion] Pré-bundle de", ENTRY_POINT, "→", BUNDLE_DIR); + await fs.promises.rm(BUNDLE_DIR, { recursive: true, force: true }); + await bundle(bundleOptions(BUNDLE_DIR)); + log("🌐 [Remotion] Téléchargement du Chrome headless shell…"); + await ensureBrowser(); + log("✅ [Remotion] Bundle et navigateur prêts"); +} + +if (process.argv.includes("--prepare")) { + prepare().catch((error) => { + console.error("❌ [Remotion] Préparation impossible :", error); + process.exit(1); + }); +} else { + if (!API_KEY) { + console.error("API_KEY doit être défini : le service refuse de démarrer sans clé."); + process.exit(1); + } + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + if (res.headersSent) return res.destroy(); + send(res, error?.status ?? 500, { error: error instanceof Error ? error.message : String(error) }); + }); + }); + server.listen(PORT, "0.0.0.0", () => { + log(`🎬 [Remotion] Service de rendu à l'écoute sur le port ${PORT} (rendus simultanés : ${MAX_PARALLEL_RENDERS})`); + }); + + // Bundle et navigateur prêts avant le premier rendu (sans bloquer le démarrage) + getBundle().catch((error) => warn("⚠️ [Remotion] Bundle impossible :", error.message)); + ensureBrowser().catch((error) => warn("⚠️ [Remotion] Navigateur indisponible :", error.message)); + + const shutdown = () => { + log("Arrêt du service de rendu"); + for (const render of renders.values()) render.cancel?.(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5_000).unref(); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} diff --git a/server/src/routes/reels.rs b/server/src/routes/reels.rs index 27dafc2..1bd6849 100644 --- a/server/src/routes/reels.rs +++ b/server/src/routes/reels.rs @@ -1,9 +1,471 @@ -//! STUB — routes à porter (voir `server/routes/reels.ts`). +//! Routes des Reels et de la musique (portage de `server/routes/reels.ts`). +//! Toutes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use base64::Engine; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::db::{NewMusicFavorite, NewPost, PostStatus}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, QueryMap}; +use crate::services::ffmpeg::{self, VoicePreviewOptions}; +use crate::services::openrouter; +use crate::services::reels::assets::{ + qwen_target_from_config, resolve_gemini_api_key, resolve_logo_path, resolve_qwen_target, + resolve_store_name, +}; +use crate::services::reels::params::{parse_tts_preview, parse_video_reel_params, srt_text}; +use crate::services::reels::pipeline::effective_renderer; +use crate::services::reels::queue::{count_active_reel_jobs, enqueue_reel_job}; +use crate::services::reels::tts_sync::estimate_voice_timing; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + // Musique (Jamendo désactivé : listes vides) + .route("/api/music/search", get(music_search)) + .route("/api/music/more", get(music_more)) + .route("/api/music/popular", get(music_popular)) + .route( + "/api/music/favorites", + get(list_favorites).post(add_favorite), + ) + .route("/api/music/favorites/check/{track_id}", get(check_favorite)) + .route("/api/music/favorites/{track_id}", delete(remove_favorite)) + .route("/api/music/{track_id}", get(music_details)) + // Reels + .route("/api/reels/generate-text", post(generate_text)) + .route("/api/reels/voices", get(voices)) + .route("/api/reels/tts-preview", post(tts_preview)) + .route("/api/reels/sync-info", post(sync_info)) + .route("/api/reels", post(create_reel)) + .route("/api/reels/config", get(reels_config)) + .route("/api/reels/ongoing", get(ongoing)) + .route("/api/reels/{id}", delete(delete_reel)) +} + +// --------------------------------------------------------------------------- +// Musique +// --------------------------------------------------------------------------- + +/// `parseInt(value)` : chiffres en tête (signe et espaces tolérés), sinon NaN (null en JSON). +fn js_parse_int(value: &str) -> Value { + let trimmed = value.trim_start(); + let (sign, digits) = match trimmed.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + let numeric: String = digits.chars().take_while(|c| c.is_ascii_digit()).collect(); + match numeric.parse::() { + Ok(n) => json!(sign * n), + Err(_) => Value::Null, + } +} + +fn empty_music_page(query: &QueryMap) -> Json { + Json(json!({ + "tracks": [], + "pagination": { + "offset": js_parse_int(query.get("offset").unwrap_or("0")), + "limit": js_parse_int(query.get("limit").unwrap_or("10")), + "hasMore": false, + }, + })) +} + +async fn music_search(_user: AuthUser, query: QueryMap) -> Json { + empty_music_page(&query) +} + +async fn music_more(_user: AuthUser, query: QueryMap) -> Json { + empty_music_page(&query) +} + +async fn music_popular(_user: AuthUser) -> Json { + Json(json!({ "tracks": [] })) +} + +async fn list_favorites(State(state): State, user: AuthUser) -> ApiResult> { + let favorites = state + .db + .get_music_favorites(&user.id) + .await + .or_500("Erreur lors de la récupération des favoris")?; + let tracks: Vec = favorites + .into_iter() + .map(|f| { + json!({ + "id": f.track_id, + "title": f.title, + "artist": f.artist, + "albumName": f.album_name.unwrap_or_default(), + "duration": f.duration, + "previewUrl": f.preview_url, + "downloadUrl": f.download_url, + "imageUrl": f.image_url.unwrap_or_default(), + "license": f.license.unwrap_or_default(), + }) + }) + .collect(); + Ok(Json(json!({ "tracks": tracks, "isFavorites": true }))) +} + +async fn check_favorite( + State(state): State, + user: AuthUser, + Path(track_id): Path, +) -> ApiResult> { + let is_favorite = state + .db + .is_music_favorite(&user.id, &track_id) + .await + .or_500("Erreur lors de la vérification du favori")?; + Ok(Json(json!({ "isFavorite": is_favorite }))) +} + +/// Valeur texte d'un champ JSON « truthy » (les nombres sont convertis comme en JS). +fn text_field(body: &Value, key: &str) -> Option { + let value = body.get(key); + if !is_truthy(value) { + return None; + } + match value? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + other => Some(other.to_string()), + } +} + +async fn add_favorite( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const ERROR: &str = "Erreur lors de l'ajout du favori"; + let (Some(track_id), Some(title)) = (text_field(&body, "trackId"), text_field(&body, "title")) + else { + return Err(AppError::bad_request("trackId et title requis")); + }; + + if state + .db + .is_music_favorite(&user.id, &track_id) + .await + .or_500(ERROR)? + { + return Ok(Json( + json!({ "success": true, "message": "Déjà en favori" }), + )); + } + + let duration = body + .get("duration") + .and_then(Value::as_f64) + .map(|d| d as i32) + .unwrap_or(0); + state + .db + .add_music_favorite(NewMusicFavorite { + user_id: user.id.clone(), + track_id, + title, + artist: text_field(&body, "artist").unwrap_or_default(), + album_name: text_field(&body, "albumName"), + duration, + preview_url: text_field(&body, "previewUrl").unwrap_or_default(), + download_url: text_field(&body, "downloadUrl").unwrap_or_default(), + image_url: text_field(&body, "imageUrl"), + license: text_field(&body, "license"), + }) + .await + .or_500(ERROR)?; + Ok(Json(json!({ "success": true }))) +} + +async fn remove_favorite( + State(state): State, + user: AuthUser, + Path(track_id): Path, +) -> ApiResult> { + state + .db + .remove_music_favorite(&user.id, &track_id) + .await + .or_500("Erreur lors de la suppression du favori")?; + Ok(Json(json!({ "success": true }))) +} + +async fn music_details(_user: AuthUser, Path(_track_id): Path) -> AppError { + AppError::not_found("Musique non trouvée") +} + +// --------------------------------------------------------------------------- +// Reels +// --------------------------------------------------------------------------- + +/// Textes IA pour un Reel (même service que les posts). +async fn generate_text( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let product_info = body.get("productInfo"); + if !is_truthy(product_info) { + return Err(AppError::bad_request("Informations produit requises")); + } + // Une simple chaîne sert de nom et de description du produit + let product_info = match product_info { + Some(Value::String(text)) => { + let text = text.trim(); + json!({ "name": text, "description": text }) + } + Some(other) => other.clone(), + None => Value::Null, + }; + let model = body.get("model").and_then(Value::as_str); + + let fail = |error: anyhow::Error| { + tracing::error!("❌ Error generating Reel text: {error:#}"); + let (status, message) = openrouter::describe_generation_error(&error); + AppError::new(status, message) + }; + + let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model) + .await + .map_err(fail)?; + let generated = Value::Array(texts); + state + .db + .create_ai_generation(&user.id, &product_info, &generated) + .await + .map_err(|e| fail(e.into()))?; + Ok(Json(json!({ "variants": generated }))) +} + +/// Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local. +async fn voices(State(state): State, _user: AuthUser) -> Json { + let result = async { + let config = state.db.get_app_config().await?; + ffmpeg::list_voices(&state, qwen_target_from_config(config.as_ref())).await + } + .await; + match result { + Ok(catalog) => Json(json!({ + "qwenAvailable": catalog.qwen_available, + "qwen": catalog.qwen, + "qwenStatus": catalog.qwen_status, + })), + Err(error) => { + tracing::error!("❌ Error listing voices: {error}"); + Json(json!({ "qwenAvailable": false, "qwen": [], "qwenStatus": null })) + } + } +} + +/// Aperçu de la voix. +async fn tts_preview( + State(state): State, + _user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let params = parse_tts_preview(&body).map_err(AppError::bad_request)?; + let engine = params.tts_engine.as_deref(); + + let options = async { + Ok::<_, anyhow::Error>(VoicePreviewOptions { + voice: params.tts_voice.clone(), + engine: params.tts_engine.clone(), + style: params.tts_style.clone(), + gemini_api_key: resolve_gemini_api_key(&state, engine).await?, + qwen: resolve_qwen_target(&state, engine).await?, + }) + } + .await + .map_err(|error| { + tracing::error!("❌ Error generating TTS preview: {error}"); + AppError::new(StatusCode::BAD_GATEWAY, error.to_string()) + })?; + + let preview = ffmpeg::preview_voice(&state, ¶ms.text, &options) + .await + .map_err(|error| { + tracing::error!("❌ Error generating TTS preview: {error}"); + let status = if error.status == Some(400) { + StatusCode::BAD_REQUEST + } else { + StatusCode::BAD_GATEWAY + }; + AppError::new(status, error.message) + })?; + + Ok(Json(json!({ + "success": true, + "audioBase64": base64::engine::general_purpose::STANDARD.encode(&preview.audio), + "duration": preview.duration, + "words": preview.words, + "engine": preview.engine, + "voice": preview.voice, + "warnings": preview.warnings, + }))) +} + +/// Estimation locale de la durée de la voix : ne déclenche aucune synthèse. +async fn sync_info(_user: AuthUser, Body(body): Body) -> ApiResult> { + let text = body.get("text"); + if !is_truthy(text) { + return Err(AppError::bad_request("Texte requis")); + } + let Some(text) = text.and_then(Value::as_str) else { + return Err(AppError::internal("Erreur de calcul de synchronisation")); + }; + Ok(Json(json!(estimate_voice_timing(text)))) +} + +/// Crée un Reel : le rendu part dans la file persistante. +async fn create_reel( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let mut params = parse_video_reel_params(&body).map_err(AppError::bad_request)?; + + let fail = |message: String| { + tracing::error!("❌ Error initiating Reel: {message}"); + AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "success": false, "error": message }), + ) + }; + + // Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres + if let Some(cues) = ¶ms.srt_cues { + params.overlay_text = None; + if params.description.is_none() { + params.description = Some(srt_text(cues)).filter(|d| !d.is_empty()); + } + } + params.store_name = resolve_store_name( + &state, + &user.id, + params.page_ids.first().map(String::as_str), + ) + .await; + + let waiting = count_active_reel_jobs(&state) + .await + .map_err(|e| fail(e.to_string()))?; + + let scheduled_for = match ¶ms.scheduled_for { + Some(date) => Some(Ts::parse(date).ok_or_else(|| fail("Invalid time value".into()))?), + None => None, + }; + let mut new_post = NewPost::new( + user.id.clone(), + params + .description + .clone() + .or_else(|| params.overlay_text.clone()) + .unwrap_or_default(), + ); + new_post.status = if scheduled_for.is_some() { + PostStatus::Scheduled + } else { + PostStatus::Draft + }; + new_post.scheduled_for = scheduled_for; + new_post.generation_status = Some("pending".into()); + new_post.generation_progress = Some(0); + let post = state + .db + .create_post(new_post) + .await + .map_err(|e| fail(e.to_string()))?; + + let stored = serde_json::to_value(¶ms).map_err(|e| fail(e.to_string()))?; + enqueue_reel_job(&state, "video", &user.id, Some(&post.id), stored) + .await + .map_err(|e| fail(e.to_string()))?; + tracing::info!( + "✨ Reel en file. Post {}, {waiting} job(s) avant lui.", + post.id + ); + + Ok(Json(json!({ + "success": true, + "postId": post.id, + "queued": waiting > 0, + "message": if waiting > 0 { + "File d'attente occupée. Votre vidéo sera traitée dès que possible." + } else { + "Traitement démarré en arrière-plan." + }, + "results": [], + "videoUrl": "", + }))) +} + +/// Configuration des services Reels. +async fn reels_config(State(state): State, _user: AuthUser) -> Json { + let (ffmpeg_configured, logo_url) = + tokio::join!(ffmpeg::health_check(&state), resolve_logo_path(&state)); + Json(json!({ + "jamendo": { "configured": false }, + "freesound": { "configured": false }, + "ffmpeg": { "configured": ffmpeg_configured }, + // Aperçu : logo affiché par le lecteur, moteur de rendu utilisé + "logoUrl": logo_url, + "renderer": effective_renderer(&state), + })) +} + +/// Reels en cours de génération. +async fn ongoing(State(state): State, user: AuthUser) -> ApiResult> { + let posts = state + .db + .get_ongoing_reel_posts(&user.id) + .await + .or_500("Erreur lors de la récupération des reels en cours")?; + Ok(Json(Value::Array(posts))) +} + +/// Supprime un Reel (le post et, en cascade, ses publications et liens médias). +async fn delete_reel( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const ERROR: &str = "Erreur lors de la suppression du Reel"; + let post = state + .db + .get_post(&id) + .await + .or_500(ERROR)? + .ok_or_else(|| AppError::not_found("Reel non trouvé"))?; + if !user.is_admin() && post.user_id != user.id { + return Err(AppError::forbidden("Non autorisé à supprimer ce Reel")); + } + state.db.delete_post(&id).await.or_500(ERROR)?; + Ok(Json( + json!({ "success": true, "message": "Reel supprimé avec succès" }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_int_comme_javascript() { + assert_eq!(js_parse_int("10"), json!(10)); + assert_eq!(js_parse_int("12abc"), json!(12)); + assert_eq!(js_parse_int(" -3"), json!(-3)); + assert_eq!(js_parse_int("abc"), Value::Null); + assert_eq!(js_parse_int(""), Value::Null); + } } diff --git a/server/src/services/ffmpeg.rs b/server/src/services/ffmpeg.rs index b771b70..66e8f6a 100644 --- a/server/src/services/ffmpeg.rs +++ b/server/src/services/ffmpeg.rs @@ -1,13 +1,33 @@ //! Client du service FFmpeg (conteneur Python `ffmpeg-service`). //! -//! STUB — à porter depuis `server/services/ffmpeg.ts`. -#![allow(unused_variables)] +//! Le service rend le Reel puis expose le MP4 en téléchargement +//! (GET /files/{job}/output.mp4) : la vidéo ne transite pas en base64 dans du +//! JSON. Adresse et clé viennent de `state.ffmpeg` (modifiables à chaud depuis +//! les paramètres). +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::Engine; +use bytes::Bytes; +use futures::StreamExt; +use reqwest::Method; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; +use tokio::io::AsyncWriteExt; +use crate::services::reels::params::SrtCue; use crate::state::AppState; +/// Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. +const PROCESS_TIMEOUT: Duration = Duration::from_secs(20 * 60); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer. +const TTS_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); +const VOICES_TIMEOUT: Duration = Duration::from_secs(30); + /// Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -33,12 +53,595 @@ pub struct VoiceCatalog { pub edge: Vec, } -/// Le service répond-il ? -pub async fn health_check(state: &AppState) -> bool { - false +/// Erreur renvoyée par le service, avec son message lisible et son statut HTTP. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +pub struct FFmpegServiceError { + pub message: String, + pub status: Option, } -/// 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é") +impl FFmpegServiceError { + pub fn new(message: impl Into, status: Option) -> Self { + Self { + message: message.into(), + status, + } + } +} + +/// Mot affiché et son intervalle, en secondes depuis le début de la vidéo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimedWord { + pub text: String, + pub start: f64, + pub end: f64, +} + +/// Options d'un rendu (ou d'une préparation Remotion). `None` = valeur par +/// défaut du service : le champ n'est pas envoyé, comme `undefined` en JSON. +#[derive(Debug, Clone, Default)] +pub struct ReelRenderOptions { + pub text: Option, + /// Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. + pub srt_cues: Option>, + pub music_url: Option, + pub tts_enabled: Option, + pub tts_voice: Option, + pub tts_engine: Option, + pub tts_style: Option, + pub gemini_api_key: Option, + pub qwen: Option, + pub font_size: Option, + pub music_volume: Option, + pub draw_text: Option, + pub stabilize: Option, + pub watermark_url: Option, + pub store_name: Option, + pub enable_ending_effect: Option, + /// Petit logo pendant la vidéo (le grand logo de fin dépend de `enable_ending_effect`). + pub show_logo: Option, +} + +#[derive(Debug, Clone)] +pub struct ReelRenderResult { + pub video: Bytes, + pub duration: f64, + pub tts_engine: Option, + pub tts_voice: Option, + pub warnings: Vec, +} + +/// Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. +#[derive(Debug, Clone)] +pub struct PreparedReel { + pub video_path: PathBuf, + pub audio_path: Option, + pub total_duration: f64, + pub video_duration: f64, + pub logo_start: Option, + /// Fondu final, calculé par le service pour ne jamais rogner la voix. + pub fade_start: Option, + pub fade_duration: Option, + pub words: Vec, + pub tts_engine: Option, + pub tts_voice: Option, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct VoicePreviewOptions { + pub voice: Option, + pub engine: Option, + pub style: Option, + pub gemini_api_key: Option, + pub qwen: Option, +} + +#[derive(Debug, Clone)] +pub struct VoicePreview { + pub audio: Vec, + pub duration: f64, + pub words: Vec, + pub engine: String, + pub voice: String, + pub warnings: Vec, +} + +// --------------------------------------------------------------------------- +// Appels HTTP +// --------------------------------------------------------------------------- + +struct Endpoint { + url: String, + key: String, +} + +async fn endpoint(state: &AppState) -> Result { + let settings = state.ffmpeg.read().await; + match (&settings.api_url, &settings.api_key) { + (Some(url), Some(key)) if !url.is_empty() && !key.is_empty() => Ok(Endpoint { + url: url.trim_end_matches('/').to_string(), + key: key.clone(), + }), + _ => Err(FFmpegServiceError::new( + "Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).", + None, + )), + } +} + +fn network_error(path: &str, error: reqwest::Error) -> FFmpegServiceError { + if error.is_timeout() { + FFmpegServiceError::new( + format!("Le service FFmpeg n'a pas répondu à temps ({path})"), + None, + ) + } else { + FFmpegServiceError::new(format!("Service FFmpeg injoignable : {error}"), None) + } +} + +/// Message lisible d'une réponse en erreur : `detail` du JSON FastAPI, sinon le corps. +fn error_detail(body: &str) -> String { + let detail = match serde_json::from_str::(body) { + Ok(Value::Object(map)) => match map.get("detail") { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => body.to_string(), + Some(other) => other.to_string(), + }, + _ => body.to_string(), + }; + detail.chars().take(2000).collect() +} + +async fn call( + state: &AppState, + method: Method, + path: &str, + body: Option<&Value>, + headers: &[(&str, String)], + timeout: Duration, +) -> Result { + let endpoint = endpoint(state).await?; + let mut request = state + .http + .request(method, format!("{}{path}", endpoint.url)) + .header("X-API-Key", &endpoint.key) + .timeout(timeout); + for (name, value) in headers { + request = request.header(*name, value); + } + if let Some(body) = body { + request = request.json(body); + } + let response = request.send().await.map_err(|e| network_error(path, e))?; + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(FFmpegServiceError::new( + error_detail(&text), + Some(status.as_u16()), + )); + } + Ok(response) +} + +async fn read_json( + response: reqwest::Response, + path: &str, +) -> Result { + let bytes = response.bytes().await.map_err(|e| network_error(path, e))?; + serde_json::from_slice(&bytes).map_err(|e| { + FFmpegServiceError::new( + format!("Réponse invalide du service FFmpeg ({path}) : {e}"), + None, + ) + }) +} + +/// Le fichier n'est plus utile au service une fois récupéré (sinon purgé plus tard). +fn delete_job_later(state: &AppState, job_id: &str) { + let state = state.clone(); + let path = format!("/jobs/{job_id}"); + tokio::spawn(async move { + let _ = call(&state, Method::DELETE, &path, None, &[], HEALTH_TIMEOUT).await; + }); +} + +fn put>(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), value.into()); + } +} + +/// Champs communs à /process-reel et /prepare-reel. +fn base_body(video_url: &str, options: &ReelRenderOptions) -> Map { + let mut body = Map::new(); + body.insert("video_url".into(), video_url.into()); + put(&mut body, "text", options.text.clone()); + if let Some(cues) = &options.srt_cues { + body.insert( + "srt_cues".into(), + serde_json::to_value(cues).unwrap_or(Value::Null), + ); + } + put(&mut body, "music_url", options.music_url.clone()); + body.insert( + "tts_enabled".into(), + options.tts_enabled.unwrap_or(false).into(), + ); + put(&mut body, "tts_voice", options.tts_voice.clone()); + put(&mut body, "tts_engine", options.tts_engine.clone()); + put(&mut body, "tts_style", options.tts_style.clone()); + put(&mut body, "gemini_api_key", options.gemini_api_key.clone()); + put( + &mut body, + "qwen_tts_url", + options.qwen.as_ref().map(|q| q.url.clone()), + ); + put( + &mut body, + "qwen_tts_api_key", + options.qwen.as_ref().and_then(|q| q.api_key.clone()), + ); + body +} + +/// Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit. +pub async fn render_reel( + state: &AppState, + video_url: &str, + options: &ReelRenderOptions, +) -> Result { + let mut body = base_body(video_url, options); + body.insert("font_size".into(), options.font_size.unwrap_or(64).into()); + body.insert( + "music_volume".into(), + options.music_volume.unwrap_or(0.25).into(), + ); + body.insert("draw_text".into(), options.draw_text.unwrap_or(true).into()); + body.insert( + "stabilize".into(), + options.stabilize.unwrap_or(false).into(), + ); + put(&mut body, "watermark_url", options.watermark_url.clone()); + put(&mut body, "store_name", options.store_name.clone()); + body.insert( + "enable_ending_effect".into(), + options.enable_ending_effect.unwrap_or(true).into(), + ); + body.insert( + "show_watermark".into(), + options.show_logo.unwrap_or(true).into(), + ); + + let tts = if options.tts_enabled.unwrap_or(false) { + format!( + "{}/{}/{}", + options.tts_engine.as_deref().unwrap_or("undefined"), + options.tts_voice.as_deref().unwrap_or("undefined"), + options.tts_style.as_deref().unwrap_or("neutral") + ) + } else { + "false".into() + }; + tracing::info!( + "🎬 Rendu du Reel : vidéo {video_url}, texte {} car., musique {}, voix {tts}", + options + .text + .as_deref() + .map(|t| t.chars().count()) + .unwrap_or(0), + options.music_url.is_some() + ); + + #[derive(Deserialize)] + struct ProcessResponse { + job_id: String, + output_path: String, + #[serde(default)] + duration: f64, + #[serde(default)] + tts_engine: Option, + #[serde(default)] + tts_voice: Option, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/process-reel", + Some(&Value::Object(body)), + &[], + PROCESS_TIMEOUT, + ) + .await?; + let data: ProcessResponse = read_json(response, "/process-reel").await?; + + let result = async { + let file = call( + state, + Method::GET, + &data.output_path, + None, + &[], + DOWNLOAD_TIMEOUT, + ) + .await?; + let video = file + .bytes() + .await + .map_err(|e| network_error(&data.output_path, e))?; + let warnings = data.warnings.clone().unwrap_or_default(); + for warning in &warnings { + tracing::warn!("⚠️ [FFmpeg] {warning}"); + } + Ok(ReelRenderResult { + video, + duration: data.duration, + tts_engine: data.tts_engine.clone(), + tts_voice: data.tts_voice.clone(), + warnings, + }) + } + .await; + delete_job_later(state, &data.job_id); + result +} + +/// Prépare un rendu Remotion : image recadrée à la durée finale et piste son +/// finale, écrites dans `target_dir`, plus le minutage des mots. +pub async fn prepare_reel( + state: &AppState, + video_url: &str, + options: &ReelRenderOptions, + has_logo: bool, + target_dir: &Path, +) -> Result { + let mut body = base_body(video_url, options); + body.insert( + "music_volume".into(), + options.music_volume.unwrap_or(0.25).into(), + ); + body.insert("draw_text".into(), options.draw_text.unwrap_or(true).into()); + body.insert( + "stabilize".into(), + options.stabilize.unwrap_or(false).into(), + ); + put(&mut body, "store_name", options.store_name.clone()); + body.insert( + "enable_ending_effect".into(), + options.enable_ending_effect.unwrap_or(true).into(), + ); + body.insert("has_logo".into(), has_logo.into()); + + #[derive(Deserialize)] + struct PrepareResponse { + job_id: String, + video_path: String, + #[serde(default)] + audio_path: Option, + #[serde(default)] + total_duration: f64, + #[serde(default)] + video_duration: f64, + #[serde(default)] + logo_start: Option, + #[serde(default)] + fade_start: Option, + #[serde(default)] + fade_duration: Option, + #[serde(default)] + words: Option>, + #[serde(default)] + tts_engine: Option, + #[serde(default)] + tts_voice: Option, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/prepare-reel", + Some(&Value::Object(body)), + &[], + PROCESS_TIMEOUT, + ) + .await?; + let data: PrepareResponse = read_json(response, "/prepare-reel").await?; + + let result = async { + tokio::fs::create_dir_all(target_dir).await.map_err(|e| { + FFmpegServiceError::new(format!("Dossier de rendu inaccessible : {e}"), None) + })?; + let video_path = target_dir.join("video.mp4"); + download_to(state, &data.video_path, &video_path).await?; + let audio_path = match &data.audio_path { + Some(path) => { + let target = target_dir.join("audio.wav"); + download_to(state, path, &target).await?; + Some(target) + } + None => None, + }; + let warnings = data.warnings.clone().unwrap_or_default(); + for warning in &warnings { + tracing::warn!("⚠️ [FFmpeg] {warning}"); + } + Ok(PreparedReel { + video_path, + audio_path, + total_duration: data.total_duration, + video_duration: data.video_duration, + logo_start: data.logo_start, + fade_start: data.fade_start, + fade_duration: data.fade_duration, + words: data.words.clone().unwrap_or_default(), + tts_engine: data.tts_engine.clone(), + tts_voice: data.tts_voice.clone(), + warnings, + }) + } + .await; + delete_job_later(state, &data.job_id); + result +} + +/// Télécharge un fichier du service en flux, sans le charger en mémoire. +async fn download_to( + state: &AppState, + service_path: &str, + target: &Path, +) -> Result<(), FFmpegServiceError> { + let response = call( + state, + Method::GET, + service_path, + None, + &[], + DOWNLOAD_TIMEOUT, + ) + .await?; + let write_error = |e: std::io::Error| { + FFmpegServiceError::new( + format!("Écriture de {} impossible : {e}", target.display()), + None, + ) + }; + let mut file = tokio::fs::File::create(target).await.map_err(write_error)?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| network_error(service_path, e))?; + file.write_all(&chunk).await.map_err(write_error)?; + } + file.flush().await.map_err(write_error)?; + Ok(()) +} + +/// Le service répond-il ? +pub async fn health_check(state: &AppState) -> bool { + call(state, Method::GET, "/health", None, &[], HEALTH_TIMEOUT) + .await + .is_ok() +} + +/// Voix proposées par moteur ; `qwen` remplace la cible Qwen configurée côté service. +pub async fn list_voices( + state: &AppState, + qwen: Option, +) -> anyhow::Result { + let mut headers: Vec<(&str, String)> = Vec::new(); + if let Some(qwen) = &qwen { + if !qwen.url.is_empty() { + headers.push(("X-Qwen-Url", qwen.url.clone())); + } + if let Some(key) = qwen.api_key.as_ref().filter(|k| !k.is_empty()) { + headers.push(("X-Qwen-Key", key.clone())); + } + } + let response = call( + state, + Method::GET, + "/voices", + None, + &headers, + VOICES_TIMEOUT, + ) + .await?; + Ok(read_json(response, "/voices").await?) +} + +/// Génère la voix seule (aperçu), avec le minutage de chaque mot. +pub async fn preview_voice( + state: &AppState, + text: &str, + options: &VoicePreviewOptions, +) -> Result { + let mut body = Map::new(); + body.insert("text".into(), text.into()); + put(&mut body, "tts_voice", options.voice.clone()); + put(&mut body, "tts_engine", options.engine.clone()); + put(&mut body, "tts_style", options.style.clone()); + put(&mut body, "gemini_api_key", options.gemini_api_key.clone()); + put( + &mut body, + "qwen_tts_url", + options.qwen.as_ref().map(|q| q.url.clone()), + ); + put( + &mut body, + "qwen_tts_api_key", + options.qwen.as_ref().and_then(|q| q.api_key.clone()), + ); + + #[derive(Deserialize)] + struct PreviewResponse { + #[serde(default)] + audio_base64: String, + #[serde(default)] + duration: f64, + #[serde(default)] + words: Option>, + #[serde(default)] + engine: String, + #[serde(default)] + voice: String, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/preview-tts", + Some(&Value::Object(body)), + &[], + TTS_TIMEOUT, + ) + .await?; + let data: PreviewResponse = read_json(response, "/preview-tts").await?; + let audio = base64::engine::general_purpose::STANDARD + .decode(data.audio_base64.trim()) + .map_err(|e| FFmpegServiceError::new(format!("Audio de l'aperçu illisible : {e}"), None))?; + Ok(VoicePreview { + audio, + duration: data.duration, + words: data.words.unwrap_or_default(), + engine: data.engine, + voice: data.voice, + warnings: data.warnings.unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detail_des_erreurs() { + assert_eq!( + error_detail(r#"{"detail":"Vidéo illisible"}"#), + "Vidéo illisible" + ); + assert_eq!( + error_detail("Internal Server Error"), + "Internal Server Error" + ); + assert_eq!(error_detail(r#"{"other":1}"#), r#"{"other":1}"#); + assert_eq!(error_detail(&"x".repeat(3000)).len(), 2000); + } + + #[test] + fn champs_absents_non_envoyes() { + let body = base_body("http://app/v.mp4", &ReelRenderOptions::default()); + assert_eq!(body.get("tts_enabled"), Some(&Value::Bool(false))); + // tts_engine absent : le service prend sa valeur par défaut (gemini) + assert!(!body.contains_key("tts_engine")); + assert!(!body.contains_key("text")); + } } diff --git a/server/src/services/reels/assets.rs b/server/src/services/reels/assets.rs new file mode 100644 index 0000000..0fe1a85 --- /dev/null +++ b/server/src/services/reels/assets.rs @@ -0,0 +1,103 @@ +//! Résolution des ressources d'un Reel : musique, logo, clé Gemini, service +//! Qwen et nom du magasin. + +use crate::db::AppConfig; +use crate::services::ffmpeg::QwenTarget; +use crate::services::media_store; +use crate::state::AppState; + +/// URL de la musique téléchargeable par le service FFmpeg (réseau Docker +/// interne : l'URL publique HTTPS n'y est pas joignable). +pub async fn resolve_music_url( + state: &AppState, + music_track_id: Option<&str>, + music_url: Option<&str>, +) -> Option { + if let Some(url) = music_url.filter(|u| !u.is_empty()) { + return Some(media_store::resolve_internal_url(state, url)); + } + let id = music_track_id?.strip_prefix("internal_")?; + match state.db.get_audio_track(id).await { + Ok(track) => track.map(|t| media_store::resolve_internal_url(state, &t.url)), + Err(error) => { + tracing::error!("⚠️ [Reels] Piste audio introuvable : {error}"); + None + } + } +} + +/// Chemin relatif (/uploads/...) du logo configuré, s'il existe. +pub async fn resolve_logo_path(state: &AppState) -> Option { + match state.db.get_cloudinary_config().await { + Ok(config) => config + .and_then(|c| c.logo_public_id) + .filter(|id| !id.is_empty()) + .map(|id| media_store::build_url(&id)), + Err(error) => { + tracing::error!("⚠️ [Reels] Configuration du logo illisible : {error}"); + None + } + } +} + +/// Clé Gemini : configuration de l'application, sinon variable d'environnement. +/// Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue. +pub async fn resolve_gemini_api_key( + state: &AppState, + tts_engine: Option<&str>, +) -> anyhow::Result> { + if !matches!(tts_engine, Some("gemini") | Some("qwen")) { + return Ok(None); + } + let app_config = state.db.get_app_config().await?; + Ok(app_config + .and_then(|c| c.gemini_api_key) + .or_else(|| state.config.gemini_api_key.clone())) +} + +/// Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). +pub async fn resolve_qwen_target( + state: &AppState, + tts_engine: Option<&str>, +) -> anyhow::Result> { + if tts_engine != Some("qwen") { + return Ok(None); + } + Ok(qwen_target_from_config( + state.db.get_app_config().await?.as_ref(), + )) +} + +pub fn qwen_target_from_config(app_config: Option<&AppConfig>) -> Option { + let config = app_config?; + let url = config.qwen_tts_url.as_ref().filter(|u| !u.is_empty())?; + Some(QwenTarget { + url: url.clone(), + api_key: config.qwen_tts_api_key.clone(), + }) +} + +/// Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. +pub async fn resolve_store_name( + state: &AppState, + user_id: &str, + preferred_page_id: Option<&str>, +) -> Option { + let result: Result, sqlx::Error> = async { + if let Some(page_id) = preferred_page_id.filter(|p| !p.is_empty()) { + if let Some(page) = state.db.get_social_page(page_id).await? { + return Ok(Some(page.page_name)); + } + } + let pages = state.db.get_social_pages(user_id).await?; + Ok(pages.into_iter().next().map(|p| p.page_name)) + } + .await; + match result { + Ok(name) => name, + Err(error) => { + tracing::error!("⚠️ [Reels] Nom du magasin introuvable : {error}"); + None + } + } +} diff --git a/server/src/services/reels/mod.rs b/server/src/services/reels/mod.rs index cda7019..10b2bb9 100644 --- a/server/src/services/reels/mod.rs +++ b/server/src/services/reels/mod.rs @@ -1,14 +1,24 @@ -//! Reels : file de rendus persistante, montage, publication. -//! -//! STUB — à porter depuis `server/services/reels/*`. -#![allow(unused_variables)] +//! Reels : file de rendus persistante, montage (service FFmpeg + service +//! Remotion), publication. + +pub mod assets; +pub mod params; +pub mod pipeline; +pub mod publish; +pub mod queue; +pub mod remotion; +pub mod tts_sync; use serde_json::Value; use crate::state::AppState; -/// Démarre le worker de la file `reel_jobs`. -pub fn start(state: AppState) {} +/// Démarre le worker de la file `reel_jobs` : récupération des jobs +/// orphelins, puis traitement de la file (réveil à l'ajout d'un job et +/// toutes les 10 s). +pub fn start(state: AppState) { + queue::start(state); +} /// 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 { @@ -22,3 +32,20 @@ pub fn normalize_qwen_url(value: Option<&Value>) -> Option { } Some(url.to_string().trim_end_matches('/').to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn adresse_qwen() { + assert_eq!( + normalize_qwen_url(Some(&json!(" http://gpu:8001/ "))).as_deref(), + Some("http://gpu:8001") + ); + assert_eq!(normalize_qwen_url(Some(&json!("ftp://gpu"))), None); + assert_eq!(normalize_qwen_url(Some(&json!(""))), None); + assert_eq!(normalize_qwen_url(None), None); + } +} diff --git a/server/src/services/reels/params.rs b/server/src/services/reels/params.rs new file mode 100644 index 0000000..9bcf7a1 --- /dev/null +++ b/server/src/services/reels/params.rs @@ -0,0 +1,591 @@ +//! Paramètres des rendus de Reels (portage des schémas zod de `shared/reel.ts` +//! et `shared/srt.ts`). +//! +//! Validés à l'entrée des routes et stockés tels quels (JSON camelCase) dans +//! `reel_jobs.params` : un job repris après redémarrage — ou mis en file par +//! l'ancien serveur — retrouve exactement ce que l'utilisateur avait demandé. +//! Les messages reproduisent ceux de zod : seul le premier problème est +//! renvoyé au client (`{ error }`, statut 400). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const TTS_ENGINES: [&str; 3] = ["qwen", "gemini", "edge"]; +pub const TTS_STYLES: [&str; 5] = ["neutral", "dynamic", "warm", "calm", "promo"]; +pub const CAPTION_STYLES: [&str; 3] = ["bold", "neon", "minimal"]; +pub const DEFAULT_CAPTION_STYLE: &str = "bold"; +pub const MAX_SRT_CUES: usize = 300; + +/// Sous-titre d'un fichier SRT (secondes). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SrtCue { + pub start: f64, + pub end: f64, + pub text: String, +} + +/// Reel construit à partir d'une vidéo de la médiathèque. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoReelParams { + pub video_media_id: String, + pub page_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub music_track_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub music_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay_text: Option, + /// Sous-titres SRT : remplacent le texte libre, la voix lit chacun à son instant. + #[serde(skip_serializing_if = "Option::is_none")] + pub srt_cues: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub tts_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_voice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_for: Option, + /// Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok). + pub also_story: bool, + /// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même. + #[serde(skip_serializing_if = "Option::is_none")] + pub word_duration: Option, + pub font_size: i64, + pub music_volume: f64, + pub draw_text: bool, + pub caption_style: String, + pub stabilize: bool, + /// Petit logo pendant la vidéo ; le grand logo de fin dépend de `enable_ending_effect`. + pub show_logo: bool, + pub enable_ending_effect: bool, + /// Déterminé par le serveur à partir de la première page, jamais par le client. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_name: Option, +} + +/// Aperçu de la voix. +#[derive(Debug, Clone, PartialEq)] +pub struct TtsPreviewParams { + pub text: String, + pub tts_voice: Option, + pub tts_engine: Option, + pub tts_style: Option, +} + +/// Texte complet du SRT (description de la publication, aperçu de la voix). +pub fn srt_text(cues: &[SrtCue]) -> String { + cues.iter() + .map(|c| c.text.as_str()) + .collect::>() + .join(" ") +} + +// --------------------------------------------------------------------------- +// Petit équivalent de zod : chaque fonction renvoie le premier problème +// rencontré, avec le message par défaut de zod v3 (ou le message personnalisé). +// --------------------------------------------------------------------------- + +type Issue = String; + +/// Type reçu, tel que le nomme zod (`getParsedType`). +fn parsed_type(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +fn invalid_type(expected: &str, value: Option<&Value>) -> Issue { + match value { + None => "Required".into(), + Some(_) => format!("Expected {expected}, received {}", parsed_type(value)), + } +} + +/// `String.prototype.trim()` : espaces Unicode et BOM. +pub fn js_trim(text: &str) -> &str { + text.trim_matches(|c: char| (c.is_whitespace() && c != '\u{85}') || c == '\u{FEFF}') +} + +/// Longueur JavaScript (unités UTF-16). +fn js_len(text: &str) -> usize { + text.encode_utf16().count() +} + +fn join_values(options: &[&str]) -> String { + options + .iter() + .map(|o| format!("'{o}'")) + .collect::>() + .join(" | ") +} + +/// `z.string().trim().optional().transform(v => v || undefined)`. +fn optional_text(obj: &Map, key: &str) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + Some(Value::String(s)) => { + let trimmed = js_trim(s); + Ok((!trimmed.is_empty()).then(|| trimmed.to_string())) + } + other => Err(invalid_type("string", other)), + } +} + +/// `z.string().optional()` (sans trim). +fn optional_string(obj: &Map, key: &str) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + other => Err(invalid_type("string", other)), + } +} + +/// `z.boolean().default(default)`. +fn bool_or(obj: &Map, key: &str, default: bool) -> Result { + match obj.get(key) { + None => Ok(default), + Some(Value::Bool(b)) => Ok(*b), + other => Err(invalid_type("boolean", other)), + } +} + +fn enum_value(value: Option<&Value>, options: &[&str]) -> Result { + match value { + Some(Value::String(s)) if options.contains(&s.as_str()) => Ok(s.clone()), + Some(Value::String(s)) => Err(format!( + "Invalid enum value. Expected {}, received '{s}'", + join_values(options) + )), + other => Err(invalid_type(&join_values(options), other)), + } +} + +/// `z.enum(options).optional()`. +fn optional_enum( + obj: &Map, + key: &str, + options: &[&str], +) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + value => enum_value(value, options).map(Some), + } +} + +fn number(value: Option<&Value>) -> Result { + match value { + Some(Value::Number(n)) => n.as_f64().ok_or_else(|| invalid_type("number", value)), + other => Err(invalid_type("number", other)), + } +} + +fn check_min(n: f64, min: f64, label: &str) -> Result<(), Issue> { + if n < min { + return Err(format!("Number must be greater than or equal to {label}")); + } + Ok(()) +} + +fn check_max(n: f64, max: f64, label: &str) -> Result<(), Issue> { + if n > max { + return Err(format!("Number must be less than or equal to {label}")); + } + Ok(()) +} + +fn as_object(value: &Value) -> Result<&Map, Issue> { + match value { + Value::Object(map) => Ok(map), + other => Err(invalid_type("object", Some(other))), + } +} + +/// `srtCueSchema` : bornes, texte de 1 à 500 caractères, fin après le début. +fn srt_cue(value: &Value) -> Result { + let obj = as_object(value)?; + let start = number(obj.get("start"))?; + check_min(start, 0.0, "0")?; + let end = number(obj.get("end"))?; + check_min(end, 0.0, "0")?; + let text = match obj.get("text") { + Some(Value::String(s)) => js_trim(s).to_string(), + other => return Err(invalid_type("string", other)), + }; + if js_len(&text) < 1 { + return Err("String must contain at least 1 character(s)".into()); + } + if js_len(&text) > 500 { + return Err("String must contain at most 500 character(s)".into()); + } + if end <= start { + return Err("Sous-titre de durée nulle".into()); + } + Ok(SrtCue { start, end, text }) +} + +/// `srtCuesSchema.optional()`. +fn srt_cues(obj: &Map, key: &str) -> Result>, Issue> { + let items = match obj.get(key) { + None => return Ok(None), + Some(Value::Array(items)) => items, + other => return Err(invalid_type("array", other)), + }; + if items.is_empty() { + return Err("Fichier SRT vide".into()); + } + if items.len() > MAX_SRT_CUES { + return Err(format!( + "Fichier SRT trop long ({MAX_SRT_CUES} sous-titres au plus)" + )); + } + items + .iter() + .map(srt_cue) + .collect::, _>>() + .map(Some) +} + +/// Valide les paramètres d'un Reel vidéo (`videoReelParamsSchema`). +/// Les clés inconnues sont ignorées, les valeurs par défaut appliquées. +pub fn parse_video_reel_params(value: &Value) -> Result { + let obj = as_object(value)?; + + let video_media_id = match obj.get("videoMediaId") { + None => return Err("Vidéo requise".into()), + Some(Value::String(s)) if s.is_empty() => return Err("Vidéo requise".into()), + Some(Value::String(s)) => s.clone(), + other => return Err(invalid_type("string", other)), + }; + + let page_ids = match obj.get("pageIds") { + None => return Err("Au moins une page requise".into()), + Some(Value::Array(items)) => { + if items.is_empty() { + return Err("Au moins une page requise".into()); + } + let mut ids = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::String(s) if s.is_empty() => { + return Err("String must contain at least 1 character(s)".into()) + } + Value::String(s) => ids.push(s.clone()), + other => return Err(invalid_type("string", Some(other))), + } + } + ids + } + other => return Err(invalid_type("array", other)), + }; + + let music_track_id = optional_text(obj, "musicTrackId")?; + let music_url = optional_text(obj, "musicUrl")?; + let overlay_text = optional_text(obj, "overlayText")?; + let srt_cues = srt_cues(obj, "srtCues")?; + let description = optional_text(obj, "description")?; + let tts_enabled = bool_or(obj, "ttsEnabled", false)?; + let tts_voice = optional_text(obj, "ttsVoice")?; + let tts_engine = optional_enum(obj, "ttsEngine", &TTS_ENGINES)?; + let tts_style = optional_enum(obj, "ttsStyle", &TTS_STYLES)?; + let scheduled_for = optional_text(obj, "scheduledFor")?; + let also_story = bool_or(obj, "alsoStory", false)?; + let word_duration = match obj.get("wordDuration") { + None => None, + value => Some(number(value)?), + }; + + let font_size = match obj.get("fontSize") { + None => 64, + value => { + let n = number(value)?; + if n.fract() != 0.0 || !n.is_finite() { + return Err("Expected integer, received float".into()); + } + check_min(n, 16.0, "16")?; + check_max(n, 200.0, "200")?; + n as i64 + } + }; + + let music_volume = match obj.get("musicVolume") { + None => 0.25, + value => { + let n = number(value)?; + check_min(n, 0.0, "0")?; + check_max(n, 2.0, "2")?; + n + } + }; + + let draw_text = bool_or(obj, "drawText", true)?; + let caption_style = match obj.get("captionStyle") { + None => DEFAULT_CAPTION_STYLE.to_string(), + value => enum_value(value, &CAPTION_STYLES)?, + }; + let stabilize = bool_or(obj, "stabilize", false)?; + let show_logo = bool_or(obj, "showLogo", true)?; + let enable_ending_effect = bool_or(obj, "enableEndingEffect", true)?; + let store_name = optional_string(obj, "storeName")?; + + Ok(VideoReelParams { + video_media_id, + page_ids, + music_track_id, + music_url, + overlay_text, + srt_cues, + description, + tts_enabled, + tts_voice, + tts_engine, + tts_style, + scheduled_for, + also_story, + word_duration, + font_size, + music_volume, + draw_text, + caption_style, + stabilize, + show_logo, + enable_ending_effect, + store_name, + }) +} + +/// Valide une demande d'aperçu de la voix (`ttsPreviewSchema`). +pub fn parse_tts_preview(value: &Value) -> Result { + let obj = as_object(value)?; + let text = match obj.get("text") { + None => return Err("Texte requis".into()), + Some(Value::String(s)) => js_trim(s).to_string(), + other => return Err(invalid_type("string", other)), + }; + if js_len(&text) < 1 { + return Err("Texte requis".into()); + } + if js_len(&text) > 2000 { + return Err("String must contain at most 2000 character(s)".into()); + } + Ok(TtsPreviewParams { + text, + tts_voice: optional_text(obj, "ttsVoice")?, + tts_engine: optional_enum(obj, "ttsEngine", &TTS_ENGINES)?, + tts_style: optional_enum(obj, "ttsStyle", &TTS_STYLES)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn base() -> Value { + json!({ "videoMediaId": "v", "pageIds": ["p"] }) + } + + fn with(extra: Value) -> Value { + let mut value = base(); + for (k, v) in extra.as_object().unwrap() { + value[k] = v.clone(); + } + value + } + + #[test] + fn exige_une_video_et_une_page() { + assert_eq!( + parse_video_reel_params(&json!({ "pageIds": [] })).unwrap_err(), + "Vidéo requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "" })).unwrap_err(), + "Vidéo requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "v", "pageIds": [] })).unwrap_err(), + "Au moins une page requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "v" })).unwrap_err(), + "Au moins une page requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": 3, "pageIds": ["p"] })).unwrap_err(), + "Expected string, received number" + ); + assert_eq!( + parse_video_reel_params(&json!([])).unwrap_err(), + "Expected object, received array" + ); + } + + #[test] + fn applique_les_valeurs_par_defaut() { + let params = parse_video_reel_params(&base()).unwrap(); + assert_eq!(params.caption_style, "bold"); + assert!(!params.stabilize); + assert!(params.draw_text); + assert_eq!(params.music_volume, 0.25); + assert!(params.show_logo); + assert!(!params.also_story); + assert!(!params.tts_enabled); + assert!(params.enable_ending_effect); + assert_eq!(params.font_size, 64); + } + + #[test] + fn json_stocke_en_camel_case_sans_champs_vides() { + let params = parse_video_reel_params(&with(json!({ + "overlayText": " Bonjour ", + "description": " ", + "inconnu": 1, + }))) + .unwrap(); + let stored = serde_json::to_value(¶ms).unwrap(); + assert_eq!(stored["overlayText"], "Bonjour"); + assert!(stored.get("description").is_none()); + assert!(stored.get("inconnu").is_none()); + assert_eq!(stored["captionStyle"], "bold"); + assert_eq!(stored["enableEndingEffect"], true); + // Relu par le worker : identique + assert_eq!(parse_video_reel_params(&stored).unwrap(), params); + } + + #[test] + fn accepte_les_anciens_parametres() { + assert!(parse_video_reel_params(&with(json!({ "wordDuration": 0.6 }))).is_ok()); + } + + #[test] + fn refuse_un_moteur_de_voix_inconnu() { + assert_eq!( + parse_video_reel_params(&with(json!({ "ttsEngine": "autre" }))).unwrap_err(), + "Invalid enum value. Expected 'qwen' | 'gemini' | 'edge', received 'autre'" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "ttsStyle": null }))).unwrap_err(), + "Expected 'neutral' | 'dynamic' | 'warm' | 'calm' | 'promo', received null" + ); + } + + #[test] + fn bornes_numeriques() { + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 10 }))).unwrap_err(), + "Number must be greater than or equal to 16" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 300 }))).unwrap_err(), + "Number must be less than or equal to 200" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 20.5 }))).unwrap_err(), + "Expected integer, received float" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 72.0 }))) + .unwrap() + .font_size, + 72 + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicVolume": 3 }))).unwrap_err(), + "Number must be less than or equal to 2" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicVolume": -1 }))).unwrap_err(), + "Number must be greater than or equal to 0" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicTrackId": null }))).unwrap_err(), + "Expected string, received null" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "stabilize": "oui" }))).unwrap_err(), + "Expected boolean, received string" + ); + } + + #[test] + fn sous_titres_srt() { + let ok = parse_video_reel_params(&with( + json!({ "srtCues": [{ "start": 0, "end": 1, "text": " a " }] }), + )) + .unwrap(); + assert_eq!(ok.srt_cues.unwrap()[0].text, "a"); + assert_eq!( + parse_video_reel_params(&with( + json!({ "srtCues": [{ "start": 1, "end": 1, "text": "a" }] }) + )) + .unwrap_err(), + "Sous-titre de durée nulle" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": [] }))).unwrap_err(), + "Fichier SRT vide" + ); + let many: Vec = (0..301) + .map(|i| json!({ "start": i, "end": i + 1, "text": "x" })) + .collect(); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": many }))).unwrap_err(), + "Fichier SRT trop long (300 sous-titres au plus)" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": [{ "end": 1, "text": "a" }] }))) + .unwrap_err(), + "Required" + ); + } + + #[test] + fn texte_du_srt() { + let cues = vec![ + SrtCue { + start: 1.0, + end: 3.5, + text: "Bonjour à tous !".into(), + }, + SrtCue { + start: 4.0, + end: 6.25, + text: "Découvrez nos nouveautés en magasin".into(), + }, + ]; + assert_eq!( + srt_text(&cues), + "Bonjour à tous ! Découvrez nos nouveautés en magasin" + ); + } + + #[test] + fn apercu_de_la_voix() { + assert_eq!( + parse_tts_preview(&json!({ "text": " " })).unwrap_err(), + "Texte requis" + ); + assert_eq!(parse_tts_preview(&json!({})).unwrap_err(), "Texte requis"); + assert_eq!( + parse_tts_preview(&json!({ "text": "x".repeat(2001) })).unwrap_err(), + "String must contain at most 2000 character(s)" + ); + let ok = + parse_tts_preview(&json!({ "text": " Salut ", "ttsEngine": "edge", "ttsVoice": "" })) + .unwrap(); + assert_eq!(ok.text, "Salut"); + assert_eq!(ok.tts_engine.as_deref(), Some("edge")); + assert_eq!(ok.tts_voice, None); + } +} diff --git a/server/src/services/reels/pipeline.rs b/server/src/services/reels/pipeline.rs new file mode 100644 index 0000000..53d3680 --- /dev/null +++ b/server/src/services/reels/pipeline.rs @@ -0,0 +1,256 @@ +//! Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication. +//! Exécuté par la file `reel_jobs` (voir `queue.rs`). +//! +//! Deux moteurs : +//! - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR, +//! stabilisation) et la piste son finale, le service Remotion compose les +//! sous-titres animés, le logo et l'effet de fin — le même rendu que l'aperçu ; +//! - « ffmpeg » : tout est fait par le service Python (sous-titres ASS), +//! plus rapide, conservé en secours (`REEL_RENDERER=ffmpeg`). + +use std::time::Instant; + +use anyhow::{anyhow, bail}; +use bytes::Bytes; +use serde_json::{json, Map, Value}; + +use super::assets::{ + resolve_gemini_api_key, resolve_logo_path, resolve_music_url, resolve_qwen_target, +}; +use super::params::{parse_video_reel_params, VideoReelParams}; +use super::publish::{ + describe_publish_failure, publish_reel_to_pages, store_rendered_video, PublishOptions, +}; +use super::queue::JobContext; +use super::remotion::{render_composition, temp_file_url, to_data_url}; +use crate::db::MediaType; +use crate::services::ffmpeg::{self, ReelRenderOptions}; +use crate::services::media_store; +use crate::state::AppState; + +/// Moteur demandé par la configuration (`REEL_RENDERER`). +pub fn configured_renderer(state: &AppState) -> &'static str { + if state.config.reel_renderer == "ffmpeg" { + "ffmpeg" + } else { + "remotion" + } +} + +/// Moteur réellement utilisé : Remotion exige l'adresse de son service. +pub fn effective_renderer(state: &AppState) -> &'static str { + match configured_renderer(state) { + "remotion" if state.config.remotion_url.is_some() => "remotion", + _ => "ffmpeg", + } +} + +pub async fn run_video_reel_job(ctx: &JobContext) -> anyhow::Result { + let state = &ctx.state; + let job = &ctx.job; + let post_id = job + .post_id + .as_deref() + .ok_or_else(|| anyhow!("Job vidéo sans post associé"))?; + let params = parse_video_reel_params(&job.params).map_err(|issue| anyhow!(issue))?; + + ctx.progress(5, "prepare").await?; + let media = state.db.get_media_by_id(¶ms.video_media_id).await?; + let Some(media) = media.filter(|m| m.media_type == MediaType::Video) else { + bail!("Vidéo source introuvable ou invalide"); + }; + + let engine = params.tts_engine.as_deref(); + let (music_url, logo_path, gemini_api_key, qwen) = tokio::join!( + resolve_music_url( + state, + params.music_track_id.as_deref(), + params.music_url.as_deref() + ), + resolve_logo_path(state), + resolve_gemini_api_key(state, engine), + resolve_qwen_target(state, engine), + ); + + let options = ReelRenderOptions { + text: if params.srt_cues.is_some() { + None + } else { + params.overlay_text.clone() + }, + srt_cues: params.srt_cues.clone(), + music_url, + tts_enabled: Some(params.tts_enabled), + tts_voice: params.tts_voice.clone(), + tts_engine: params.tts_engine.clone(), + tts_style: params.tts_style.clone(), + gemini_api_key: gemini_api_key?, + qwen: qwen?, + font_size: Some(params.font_size), + music_volume: Some(params.music_volume), + draw_text: Some(params.draw_text), + stabilize: Some(params.stabilize), + watermark_url: None, + store_name: params.store_name.clone(), + enable_ending_effect: Some(params.enable_ending_effect), + show_logo: Some(params.show_logo), + }; + + let renderer = match (configured_renderer(state), &state.config.remotion_url) { + ("remotion", None) => { + tracing::warn!( + "⚠️ [Reels] REMOTION_RENDERER_URL non défini : rendu par le service FFmpeg à la place de Remotion" + ); + "ffmpeg" + } + (renderer, _) => renderer, + }; + + let video_url = media_store::resolve_internal_url(state, &media.original_url); + let started = Instant::now(); + let video = match (renderer, state.config.remotion_url.as_deref()) { + ("remotion", Some(base_url)) => { + render_with_remotion( + ctx, + base_url, + &video_url, + &options, + ¶ms, + logo_path.as_deref(), + ) + .await? + } + _ => render_with_ffmpeg(ctx, &video_url, options, logo_path.as_deref()).await?, + }; + tracing::info!( + "⏱️ [Reels] Rendu {renderer} : {:.1} s", + started.elapsed().as_secs_f64() + ); + + ctx.progress(88, "store").await?; + let file_name = format!("reel-{}.mp4", chrono::Utc::now().timestamp_millis()); + let processed = store_rendered_video(state, &job.user_id, &video, &file_name).await?; + state + .db + .update_post_media(post_id, std::slice::from_ref(&processed.id)) + .await?; + + ctx.progress(93, "publish").await?; + let description = params + .description + .clone() + .or_else(|| params.overlay_text.clone()) + .unwrap_or_default(); + let results = publish_reel_to_pages( + state, + PublishOptions { + post_id, + page_ids: ¶ms.page_ids, + video, + description: &description, + scheduled_for: params.scheduled_for.as_deref(), + also_story: params.also_story, + }, + ) + .await?; + + if !results.iter().any(|r| r.success) { + bail!(describe_publish_failure(&results)); + } + + Ok(json!({ "mediaId": processed.id, "renderer": renderer, "results": results })) +} + +async fn render_with_ffmpeg( + ctx: &JobContext, + video_url: &str, + mut options: ReelRenderOptions, + logo_path: Option<&str>, +) -> anyhow::Result { + ctx.progress(15, "render").await?; + // Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux + let logo_used = options.show_logo != Some(false) || options.enable_ending_effect != Some(false); + options.watermark_url = logo_path + .filter(|_| logo_used) + .map(|path| media_store::resolve_internal_url(&ctx.state, path)); + let rendered = ffmpeg::render_reel(&ctx.state, video_url, &options).await?; + Ok(rendered.video) +} + +async fn render_with_remotion( + ctx: &JobContext, + base_url: &str, + video_url: &str, + options: &ReelRenderOptions, + params: &VideoReelParams, + logo_path: Option<&str>, +) -> anyhow::Result { + let state = &ctx.state; + let workdir = state + .config + .temp_upload_dir + .join(format!("reel-{}", ctx.job.id)); + let result = async { + // 1. Image recadrée à la durée finale, piste son finale, mots minutés + ctx.progress(10, "voice").await?; + let prepared = + ffmpeg::prepare_reel(state, video_url, options, logo_path.is_some(), &workdir).await?; + + // 2. Composition : sous-titres animés, logo, effet de fin + ctx.progress(40, "render").await?; + let mut props = Map::new(); + props.insert( + "videoUrl".into(), + temp_file_url(state, &prepared.video_path).into(), + ); + props.insert("videoDuration".into(), prepared.total_duration.into()); + props.insert("totalDuration".into(), prepared.total_duration.into()); + props.insert("words".into(), serde_json::to_value(&prepared.words)?); + props.insert("captionStyle".into(), params.caption_style.clone().into()); + if let Some(logo) = logo_path { + props.insert("logoUrl".into(), to_data_url(state, logo).await?.into()); + } + props.insert("showWatermark".into(), params.show_logo.into()); + if params.enable_ending_effect { + if let Some(name) = ¶ms.store_name { + props.insert("storeName".into(), name.clone().into()); + } + } + props.insert("logoStart".into(), prepared.logo_start.into()); + props.insert("fadeStart".into(), prepared.fade_start.into()); + props.insert("fadeDuration".into(), prepared.fade_duration.into()); + if let Some(audio) = &prepared.audio_path { + props.insert("mixedAudioUrl".into(), temp_file_url(state, audio).into()); + } + + let output = workdir.join("reel.mp4"); + let mut last_reported = 40; + render_composition( + state, + base_url, + "ReelVideo", + Value::Object(props), + &output, + |ratio| { + let pct = 40 + (ratio * 45.0).floor() as i32; + let report = pct >= last_reported + 5; + if report { + last_reported = pct; + } + async move { + if report { + // Non bloquant : un échec d'écriture n'interrompt pas le rendu + let _ = ctx.progress(pct, "render").await; + } + } + }, + ) + .await?; + Ok(Bytes::from(tokio::fs::read(&output).await?)) + } + .await; + + // Dossier toujours nettoyé, rendu réussi ou non + let _ = tokio::fs::remove_dir_all(&workdir).await; + result +} diff --git a/server/src/services/reels/publish.rs b/server/src/services/reels/publish.rs new file mode 100644 index 0000000..61fe8d8 --- /dev/null +++ b/server/src/services/reels/publish.rs @@ -0,0 +1,350 @@ +//! Enregistrement et publication d'un Reel rendu. +//! +//! La vidéo est toujours envoyée en binaire : les fichiers vivent sous +//! /uploads, une URL relative que Facebook ne peut pas télécharger. + +use bytes::Bytes; +use serde::Serialize; + +use crate::db::{ + Media, MediaType, NewMedia, NewScheduledPost, Patch, Platform, PostStatus, PostType, SocialPage, +}; +use crate::services::media_store::{self, MediaSource}; +use crate::services::thumbnail::{self, ThumbnailSource}; +use crate::services::{facebook, tiktok}; +use crate::state::AppState; +use crate::time::Ts; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PagePublishResult { + pub page_id: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Échec de la story associée : n'empêche pas le Reel d'être publié. + #[serde(skip_serializing_if = "Option::is_none")] + pub story_error: Option, +} + +impl PagePublishResult { + fn failed(page_id: &str, error: impl Into) -> Self { + Self { + page_id: page_id.to_string(), + success: false, + reel_id: None, + error: Some(error.into()), + story_error: None, + } + } + + fn ok(page_id: &str, reel_id: impl Into, story_error: Option) -> Self { + Self { + page_id: page_id.to_string(), + success: true, + reel_id: Some(reel_id.into()), + error: None, + story_error, + } + } +} + +/// Range la vidéo rendue dans la médiathèque, avec sa vignette. +pub async fn store_rendered_video( + state: &AppState, + user_id: &str, + video: &[u8], + file_name: &str, +) -> anyhow::Result { + let uploaded = media_store::upload_media(state, MediaSource::Bytes(video), file_name).await?; + // Vignette extraite maintenant : la vidéo sera supprimée du disque après + // publication, mais l'historique doit rester illustré. + let thumbnail_url = + thumbnail::create_video_thumbnail(state, ThumbnailSource::Bytes(video)).await; + + Ok(state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(MediaType::Video), + cloudinary_public_id: Some(uploaded.public_id), + original_url: uploaded.original_url, + facebook_landscape_url: None, + facebook_feed_url: uploaded.facebook_feed_url, + instagram_feed_url: uploaded.instagram_feed_url, + instagram_story_url: uploaded.instagram_story_url, + thumbnail_url, + file_name: file_name.to_string(), + file_size: i32::try_from(video.len()).unwrap_or(i32::MAX), + }) + .await?) +} + +pub struct PublishOptions<'a> { + pub post_id: &'a str, + pub page_ids: &'a [String], + pub video: Bytes, + pub description: &'a str, + pub scheduled_for: Option<&'a str>, + pub also_story: bool, +} + +/// `new Date(value)` ; une date invalide fait échouer l'écriture comme avec Drizzle. +fn parse_date(value: &str) -> anyhow::Result { + Ts::parse(value).ok_or_else(|| anyhow::anyhow!("Invalid time value")) +} + +/// Publie (ou planifie) la vidéo sur chaque page, crée les entrées +/// `scheduled_posts` et met à jour le statut du post. +/// +/// Avec `also_story`, chaque page Facebook reçoit aussi la vidéo en story +/// (TikTok n'a pas de stories). +pub async fn publish_reel_to_pages( + state: &AppState, + options: PublishOptions<'_>, +) -> anyhow::Result> { + let mut results = Vec::with_capacity(options.page_ids.len()); + + for page_id in options.page_ids { + match publish_to_page(state, &options, page_id).await { + Ok(result) => results.push(result), + Err(error) => { + tracing::error!( + "❌ [Reels] Publication sur la page {page_id} impossible : {error:#}" + ); + results.push(PagePublishResult::failed(page_id, error.to_string())); + } + } + } + + let succeeded = results.iter().filter(|r| r.success).count(); + let status = if options.scheduled_for.is_some() && succeeded > 0 { + PostStatus::Scheduled + } else if succeeded > 0 { + PostStatus::Published + } else { + PostStatus::Failed + }; + state + .db + .update_post(options.post_id, Patch::new().set("status", status)) + .await?; + + Ok(results) +} + +async fn publish_to_page( + state: &AppState, + options: &PublishOptions<'_>, + page_id: &str, +) -> anyhow::Result { + let Some(page) = state.db.get_social_page(page_id).await? else { + return Ok(PagePublishResult::failed(page_id, "Page non trouvée")); + }; + if page.platform != Platform::Facebook && page.platform != Platform::Tiktok { + return Ok(PagePublishResult::failed( + page_id, + format!( + "Plateforme non supportée pour les reels : {}", + page.platform + ), + )); + } + + let scheduled_at = match options.scheduled_for { + Some(date) => parse_date(date)?, + None => Ts::now(), + }; + let scheduled_post = state + .db + .create_scheduled_post(NewScheduledPost::new( + options.post_id, + &page.id, + PostType::Reel, + scheduled_at, + )) + .await?; + let with_story = options.also_story && page.platform == Platform::Facebook; + + if let Some(date) = options.scheduled_for { + // Le planificateur publiera à l'heure dite + if with_story { + state + .db + .create_scheduled_post(NewScheduledPost::new( + options.post_id, + &page.id, + PostType::Story, + parse_date(date)?, + )) + .await?; + } + return Ok(PagePublishResult::ok(page_id, "scheduled", None)); + } + + tracing::info!( + "🚀 [Reels] Publication sur {} {}...", + page.platform, + page.page_name + ); + if page.platform == Platform::Facebook { + let reel_id = facebook::publish_video_from_buffer( + state, + &page, + options.video.clone(), + options.description, + ) + .await?; + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("external_post_id", reel_id.clone()), + ) + .await?; + let story_error = if with_story { + publish_story(state, options.post_id, &page, options.video.clone()).await + } else { + None + }; + Ok(PagePublishResult::ok(page_id, reel_id, story_error)) + } else { + // TikTok finalise la publication de son côté : l'envoi est marqué comme + // effectué pour ne pas republier, et le suivi de statut renseignera + // l'identifiant définitif du post. + let publish_id = tiktok::publish_video_from_buffer( + state, + &page, + options.video.clone(), + options.description, + ) + .await?; + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("publish_id", publish_id.clone()) + .set("publish_status", "PROCESSING_UPLOAD"), + ) + .await?; + Ok(PagePublishResult::ok(page_id, publish_id, None)) + } +} + +/// Publie la story d'une page juste après son Reel : un échec est consigné sur +/// son entrée planifiée (visible dans l'historique) et renvoyé, sans faire +/// échouer le Reel. +async fn publish_story( + state: &AppState, + post_id: &str, + page: &SocialPage, + video: Bytes, +) -> Option { + // Créée seulement maintenant : en attente pendant l'envoi du Reel, le + // planificateur aurait pu la publier une seconde fois. + let mut scheduled_story_id: Option = None; + let result: anyhow::Result<()> = async { + let scheduled = state + .db + .create_scheduled_post(NewScheduledPost::new( + post_id, + &page.id, + PostType::Story, + Ts::now(), + )) + .await?; + scheduled_story_id = Some(scheduled.id.clone()); + let story_id = facebook::publish_video_story_from_buffer(state, page, video).await?; + state + .db + .update_scheduled_post( + &scheduled.id, + Patch::new() + .set("published_at", Ts::now()) + .set("external_post_id", story_id), + ) + .await?; + Ok(()) + } + .await; + + match result { + Ok(()) => None, + Err(error) => { + let message = error.to_string(); + tracing::error!( + "❌ [Reels] Story sur {} impossible : {error:#}", + page.page_name + ); + if let Some(id) = scheduled_story_id { + // Marquée comme traitée pour que le planificateur ne la republie pas en boucle + if let Err(e) = state + .db + .update_scheduled_post( + &id, + Patch::new() + .set("published_at", Ts::now()) + .set("error", message.clone()), + ) + .await + { + tracing::error!("⚠️ [Reels] Échec de la story non enregistré : {e}"); + } + } + Some(message) + } + } +} + +/// Message d'erreur lisible lorsque toutes les publications ont échoué. +pub fn describe_publish_failure(results: &[PagePublishResult]) -> String { + let details: Vec<&str> = results + .iter() + .filter_map(|r| r.error.as_deref()) + .filter(|e| !e.is_empty()) + .collect(); + if details.is_empty() { + "Publication impossible sur toutes les pages".into() + } else { + format!( + "Publication impossible sur toutes les pages : {}", + details.join(" ; ") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_d_echec_global() { + let results = vec![ + PagePublishResult::failed("a", "Page non trouvée"), + PagePublishResult::failed("b", "Jeton expiré"), + ]; + assert_eq!( + describe_publish_failure(&results), + "Publication impossible sur toutes les pages : Page non trouvée ; Jeton expiré" + ); + assert_eq!( + describe_publish_failure(&[]), + "Publication impossible sur toutes les pages" + ); + } + + #[test] + fn resultat_serialise_sans_champs_vides() { + let value = serde_json::to_value(PagePublishResult::ok("p", "scheduled", None)).unwrap(); + assert_eq!( + value, + serde_json::json!({ "pageId": "p", "success": true, "reelId": "scheduled" }) + ); + } +} diff --git a/server/src/services/reels/queue.rs b/server/src/services/reels/queue.rs new file mode 100644 index 0000000..5081191 --- /dev/null +++ b/server/src/services/reels/queue.rs @@ -0,0 +1,391 @@ +//! File d'attente persistante des rendus de Reels. +//! +//! Chaque job est une ligne de `reel_jobs` : +//! - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui +//! exclut tout double traitement, même avec plusieurs instances ; +//! - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs +//! dont le cœur ne bat plus sont remis en file (ou échouent après +//! `MAX_ATTEMPTS` tentatives). + +use std::time::Duration; + +use serde_json::Value; + +use crate::db::ReelJob; +use crate::state::AppState; +use crate::time::Ts; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); +/// Au-delà, un job « processing » est considéré comme orphelin. +const STALE_AFTER_MS: i64 = 2 * 60_000; +const POLL_INTERVAL: Duration = Duration::from_secs(10); +const MAX_ATTEMPTS: i32 = 2; + +/// Outils fournis à un traitement pour rendre compte de son avancement. +pub struct JobContext { + pub state: AppState, + pub job: ReelJob, +} + +impl JobContext { + pub async fn progress(&self, progress: i32, step: &str) -> anyhow::Result<()> { + update_job( + &self.state, + &self.job, + JobPatch { + progress: Some(progress), + step: Some(step.to_string()), + ..Default::default() + }, + ) + .await + } +} + +/// Champs modifiés d'un job (les `None` ne sont pas écrits). +#[derive(Debug, Default)] +struct JobPatch { + status: Option<&'static str>, + progress: Option, + step: Option, + result: Option, + error: Option, +} + +/// Ajoute un job à la file et réveille le worker. +pub async fn enqueue_reel_job( + state: &AppState, + kind: &str, + user_id: &str, + post_id: Option<&str>, + params: Value, +) -> Result { + let job: ReelJob = sqlx::query_as( + "INSERT INTO reel_jobs (kind, user_id, post_id, params) VALUES ($1, $2, $3, $4) RETURNING *", + ) + .bind(kind) + .bind(user_id) + .bind(post_id) + .bind(params) + .fetch_one(&state.db.pool) + .await?; + + if let Some(post_id) = &job.post_id { + sync_post(state, post_id, "pending", Some(0), None).await?; + } + + state.reel_queue.notify_one(); + Ok(job) +} + +pub async fn get_reel_job(state: &AppState, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM reel_jobs WHERE id = $1") + .bind(id) + .fetch_optional(&state.db.pool) + .await +} + +/// Nombre de jobs qui passeront avant un nouveau job (pour informer l'utilisateur). +pub async fn count_active_reel_jobs(state: &AppState) -> Result { + sqlx::query_scalar( + "SELECT count(*)::bigint FROM reel_jobs WHERE status IN ('pending', 'processing')", + ) + .fetch_one(&state.db.pool) + .await +} + +/// Démarre le worker : récupère les jobs orphelins puis traite la file. +pub fn start(state: AppState) { + tokio::spawn(async move { + if let Err(error) = recover(&state).await { + tracing::error!("❌ [ReelQueue] Récupération des jobs orphelins impossible : {error}"); + } + tracing::info!("🎬 [ReelQueue] Worker démarré (traitements : video)"); + + loop { + if let Err(error) = drain(&state).await { + tracing::error!("❌ [ReelQueue] Erreur de la boucle : {error}"); + } + // Réveil à l'ajout d'un job, ou toutes les 10 s + tokio::select! { + _ = state.reel_queue.notified() => {} + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + }); +} + +async fn recover(state: &AppState) -> anyhow::Result<()> { + recover_stale_jobs(state).await?; + fail_legacy_stuck_posts(state).await?; + Ok(()) +} + +async fn drain(state: &AppState) -> anyhow::Result<()> { + while let Some(job) = claim_next_job(state).await? { + run_job(state, job).await; + } + Ok(()) +} + +async fn claim_next_job(state: &AppState) -> Result, sqlx::Error> { + let id: Option = sqlx::query_scalar( + r#"UPDATE reel_jobs + SET status = 'processing', + attempts = attempts + 1, + heartbeat_at = now(), + updated_at = now(), + error = NULL + WHERE id = ( + SELECT id FROM reel_jobs + WHERE status = 'pending' + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING id"#, + ) + .fetch_optional(&state.db.pool) + .await?; + match id { + Some(id) => get_reel_job(state, &id).await, + None => Ok(None), + } +} + +/// Message lisible d'une panique du traitement. +fn panic_message(error: tokio::task::JoinError) -> String { + match error.try_into_panic() { + Ok(payload) => payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "Erreur inattendue pendant le rendu".into()), + Err(error) => error.to_string(), + } +} + +async fn run_job(state: &AppState, job: ReelJob) { + tracing::info!( + "🎬 [ReelQueue] Job {} ({}), tentative {}", + job.id, + job.kind, + job.attempts + ); + + let heartbeat = { + let state = state.clone(); + let id = job.id.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL); + interval.tick().await; + loop { + interval.tick().await; + if let Err(error) = + sqlx::query("UPDATE reel_jobs SET heartbeat_at = $2 WHERE id = $1") + .bind(&id) + .bind(Ts::now()) + .execute(&state.db.pool) + .await + { + tracing::warn!("⚠️ [ReelQueue] Heartbeat {id} : {error}"); + } + } + }) + }; + + // Le traitement tourne dans sa propre tâche : une panique fait échouer le + // job sans arrêter le worker. + let ctx = JobContext { + state: state.clone(), + job: job.clone(), + }; + let outcome = tokio::spawn(async move { + if ctx.job.kind != "video" { + anyhow::bail!( + "Aucun traitement enregistré pour les jobs « {} »", + ctx.job.kind + ); + } + if let Some(post_id) = &ctx.job.post_id { + sync_post(&ctx.state, post_id, "processing", Some(0), None).await?; + } + super::pipeline::run_video_reel_job(&ctx).await + }) + .await; + + let outcome = match outcome { + Ok(result) => result.map_err(|e| e.to_string()), + Err(join_error) => Err(panic_message(join_error)), + }; + + match outcome { + Ok(result) => { + let patch = JobPatch { + status: Some("completed"), + progress: Some(100), + step: Some("done".into()), + result: Some(result), + error: None, + }; + match update_job(state, &job, patch).await { + Ok(()) => tracing::info!("✅ [ReelQueue] Job {} terminé", job.id), + Err(error) => tracing::error!( + "⚠️ [ReelQueue] Statut final non enregistré pour {} : {error}", + job.id + ), + } + } + Err(message) => { + tracing::error!("❌ [ReelQueue] Job {} en échec : {message}", job.id); + let patch = JobPatch { + status: Some("failed"), + error: Some(message), + ..Default::default() + }; + if let Err(error) = update_job(state, &job, patch).await { + tracing::error!( + "⚠️ [ReelQueue] Statut d'échec non enregistré pour {} : {error}", + job.id + ); + } + } + } + heartbeat.abort(); +} + +async fn update_job(state: &AppState, job: &ReelJob, patch: JobPatch) -> anyhow::Result<()> { + let now = Ts::now(); + let mut qb = sqlx::QueryBuilder::::new("UPDATE reel_jobs SET heartbeat_at = "); + qb.push_bind(now).push(", updated_at = ").push_bind(now); + if let Some(status) = patch.status { + qb.push(", status = ").push_bind(status); + } + if let Some(progress) = patch.progress { + qb.push(", progress = ").push_bind(progress); + } + if let Some(step) = &patch.step { + qb.push(", step = ").push_bind(step.clone()); + } + if let Some(result) = &patch.result { + qb.push(", result = ").push_bind(result.clone()); + } + if let Some(error) = &patch.error { + qb.push(", error = ").push_bind(error.clone()); + } + qb.push(" WHERE id = ").push_bind(job.id.clone()); + qb.build().execute(&state.db.pool).await?; + + if let Some(post_id) = &job.post_id { + let status = patch.status.unwrap_or("processing"); + let progress = if patch.status == Some("failed") { + Some(0) + } else { + patch.progress + }; + sync_post(state, post_id, status, progress, patch.error.as_deref()).await?; + } + Ok(()) +} + +/// Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les +/// colonnes `generation_*` du post. +async fn sync_post( + state: &AppState, + post_id: &str, + status: &str, + progress: Option, + error: Option<&str>, +) -> Result<(), sqlx::Error> { + let mut qb = sqlx::QueryBuilder::::new("UPDATE posts SET generation_status = "); + qb.push_bind(status.to_string()) + .push(", updated_at = ") + .push_bind(Ts::now()); + if let Some(progress) = progress { + qb.push(", generation_progress = ").push_bind(progress); + } + if let Some(error) = error { + qb.push(", generation_error = ") + .push_bind(error.to_string()); + } + if status == "failed" { + qb.push(", status = 'failed'::post_status"); + } + qb.push(" WHERE id = ").push_bind(post_id.to_string()); + qb.build().execute(&state.db.pool).await?; + Ok(()) +} + +/// Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou +/// les fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait +/// tomber le serveur ne doit pas le faire tomber en boucle. +async fn recover_stale_jobs(state: &AppState) -> anyhow::Result<()> { + let stale_before = Ts::now().add(chrono::Duration::milliseconds(-STALE_AFTER_MS)); + let stale: Vec = sqlx::query_as( + "SELECT * FROM reel_jobs WHERE status = 'processing' AND (heartbeat_at IS NULL OR heartbeat_at < $1)", + ) + .bind(stale_before) + .fetch_all(&state.db.pool) + .await?; + + for job in stale { + if job.attempts >= MAX_ATTEMPTS { + tracing::warn!( + "⚠️ [ReelQueue] Job {} abandonné après {} tentatives", + job.id, + job.attempts + ); + update_job( + state, + &job, + JobPatch { + status: Some("failed"), + error: Some( + "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur)." + .into(), + ), + ..Default::default() + }, + ) + .await?; + } else { + tracing::warn!("♻️ [ReelQueue] Job {} interrompu, remis en file", job.id); + sqlx::query( + "UPDATE reel_jobs SET status = 'pending', step = NULL, progress = 0, updated_at = $2 WHERE id = $1", + ) + .bind(&job.id) + .bind(Ts::now()) + .execute(&state.db.pool) + .await?; + if let Some(post_id) = &job.post_id { + sync_post(state, post_id, "pending", Some(0), None).await?; + } + } + } + Ok(()) +} + +/// Posts laissés « processing »/« pending » par l'ancienne file (avant +/// reel_jobs) : aucun job ne peut les reprendre. On les marque en échec pour +/// que l'utilisateur relance. +async fn fail_legacy_stuck_posts(state: &AppState) -> Result<(), sqlx::Error> { + let result = sqlx::query( + r#"UPDATE posts + SET generation_status = 'failed', + generation_progress = 0, + generation_error = 'Génération interrompue, veuillez relancer le Reel.', + status = 'failed', + updated_at = now() + WHERE generation_status IN ('processing', 'pending') + AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)"#, + ) + .execute(&state.db.pool) + .await?; + if result.rows_affected() > 0 { + tracing::warn!( + "⚠️ [ReelQueue] {} Reel(s) de l'ancienne file marqués en échec", + result.rows_affected() + ); + } + Ok(()) +} diff --git a/server/src/services/reels/remotion.rs b/server/src/services/reels/remotion.rs new file mode 100644 index 0000000..044b3ff --- /dev/null +++ b/server/src/services/reels/remotion.rs @@ -0,0 +1,346 @@ +//! Rendu Remotion : ressources fournies aux compositions et client du service +//! de rendu (conteneur Node `remotion-service`, voir ce dossier). +//! +//! Le serveur Rust ne peut pas exécuter Remotion (React + Chromium) : il +//! confie la composition au service, qui lit la vidéo et le son préparés via +//! les URL `/uploads/temp/...` servies par l'application. + +use std::future::Future; +use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail}; +use base64::Engine; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; + +use crate::state::AppState; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// Garde-fou : un rendu bloqué ne doit pas immobiliser la file indéfiniment. +const MAX_RENDER_TIME: Duration = Duration::from_secs(60 * 60); +/// Erreurs réseau consécutives tolérées pendant le suivi (redémarrage bref, etc.). +const MAX_POLL_ERRORS: u32 = 10; + +/// Base des URL internes par lesquelles le service de rendu joint l'application. +fn internal_base(state: &AppState) -> String { + state + .config + .internal_app_url + .clone() + .unwrap_or_else(|| format!("http://localhost:{}", state.config.port)) +} + +/// URL servie (`/uploads/temp/...`) d'un fichier du dossier temporaire, +/// joignable par le service de rendu. +pub fn temp_file_url(state: &AppState, absolute_path: &Path) -> String { + let relative = absolute_path + .strip_prefix(&state.config.temp_upload_dir) + .unwrap_or(absolute_path) + .components() + .filter_map(|c| match c { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + format!("{}/uploads/temp/{relative}", internal_base(state)) +} + +/// Normalisation lexicale (`path.resolve`) : résout `.` et `..` sans toucher au disque. +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +fn absolute(path: &Path) -> PathBuf { + normalize(&std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())) +} + +fn mime_for(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => "image/png", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", + _ => "image/jpeg", + } +} + +/// Convertit un chemin local /uploads/... en data URL : les images sont +/// affichées par Chromium dans le service de rendu. Refuse tout chemin +/// sortant du dossier uploads. +pub async fn to_data_url(state: &AppState, relative_url: &str) -> anyhow::Result { + to_data_url_in( + &state.config.uploads_dir, + &state.config.temp_upload_dir, + relative_url, + ) + .await +} + +pub async fn to_data_url_in( + uploads_dir: &Path, + temp_dir: &Path, + relative_url: &str, +) -> anyhow::Result { + if !relative_url.starts_with("/uploads/") { + return Ok(relative_url.to_string()); + } + let uploads_root = absolute(uploads_dir); + let temp_root = absolute(temp_dir); + let file_path = match relative_url.strip_prefix("/uploads/temp/") { + Some(rest) => normalize(&temp_root.join(rest)), + None => normalize(&uploads_root.join(&relative_url["/uploads/".len()..])), + }; + let inside = |root: &Path| file_path.starts_with(root) && file_path != root; + if !inside(&uploads_root) && !inside(&temp_root) { + bail!("Chemin d'image refusé : {relative_url}"); + } + match tokio::fs::read(&file_path).await { + Ok(data) => Ok(format!( + "data:{};base64,{}", + mime_for(&file_path), + base64::engine::general_purpose::STANDARD.encode(data) + )), + Err(_) => { + tracing::warn!("⚠️ [Remotion] Image introuvable : {}", file_path.display()); + Ok(relative_url.to_string()) + } + } +} + +/// Clé du service de rendu : la même que celle du service FFmpeg. +async fn api_key(state: &AppState) -> String { + state + .ffmpeg + .read() + .await + .api_key + .clone() + .unwrap_or_default() +} + +/// Message d'erreur d'une réponse du service (`{ error }`), sinon le corps. +async fn response_error(response: reqwest::Response) -> String { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + let message = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string)) + .unwrap_or(text); + format!( + "Service de rendu Remotion ({}) : {}", + status.as_u16(), + message.chars().take(2000).collect::() + ) +} + +#[derive(Debug, Deserialize)] +struct RenderStatus { + status: String, + #[serde(default)] + progress: f64, + #[serde(default)] + error: Option, +} + +/// Rend une composition dans le service Remotion et écrit le MP4 dans +/// `output`. `on_progress` reçoit l'avancement (0..1). Le job du service est +/// toujours supprimé à la fin, réussi ou non. +pub async fn render_composition( + state: &AppState, + base_url: &str, + composition_id: &str, + input_props: Value, + output: &Path, + mut on_progress: F, +) -> anyhow::Result<()> +where + F: FnMut(f64) -> Fut, + Fut: Future, +{ + let base = base_url.trim_end_matches('/'); + let key = api_key(state).await; + let unreachable = |e: reqwest::Error| anyhow!("Service de rendu Remotion injoignable : {e}"); + + let response = state + .http + .post(format!("{base}/renders")) + .header("X-API-Key", &key) + .timeout(REQUEST_TIMEOUT) + .json(&json!({ "compositionId": composition_id, "inputProps": input_props })) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + bail!(response_error(response).await); + } + #[derive(Deserialize)] + struct Created { + id: String, + } + let created: Created = response.json().await.map_err(unreachable)?; + let render_url = format!("{base}/renders/{}", created.id); + + let started = Instant::now(); + let result = async { + let mut errors = 0; + loop { + if started.elapsed() > MAX_RENDER_TIME { + bail!( + "Rendu Remotion trop long (plus de {} min)", + MAX_RENDER_TIME.as_secs() / 60 + ); + } + tokio::time::sleep(POLL_INTERVAL).await; + let response = match state + .http + .get(&render_url) + .header("X-API-Key", &key) + .timeout(REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) => response, + Err(error) => { + errors += 1; + if errors >= MAX_POLL_ERRORS { + return Err(unreachable(error)); + } + continue; + } + }; + if !response.status().is_success() { + bail!(response_error(response).await); + } + let status: RenderStatus = response.json().await.map_err(unreachable)?; + errors = 0; + match status.status.as_str() { + "done" => break, + "failed" => bail!(status + .error + .unwrap_or_else(|| "Rendu Remotion en échec".into())), + _ => on_progress(status.progress.clamp(0.0, 1.0)).await, + } + } + + // Téléchargement en flux du MP4 produit + let response = state + .http + .get(format!("{render_url}/file")) + .header("X-API-Key", &key) + .timeout(DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + bail!(response_error(response).await); + } + let mut file = tokio::fs::File::create(output).await?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + file.write_all(&chunk.map_err(unreachable)?).await?; + } + file.flush().await?; + tracing::info!( + "🎞️ [Remotion] {composition_id} rendu en {:.1} s", + started.elapsed().as_secs_f64() + ); + Ok(()) + } + .await; + + // Le fichier n'est plus utile au service (sinon purgé plus tard) + let _ = state + .http + .delete(&render_url) + .header("X-API-Key", &key) + .timeout(Duration::from_secs(5)) + .send() + .await; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirs() -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("sf-remotion-test-{}", std::process::id())); + (root.join("uploads"), root.join("uploads").join("temp")) + } + + #[tokio::test] + async fn refuse_un_chemin_qui_sort_du_dossier_uploads() { + let (uploads, temp) = dirs(); + let error = to_data_url_in(&uploads, &temp, "/uploads/../../etc/passwd") + .await + .unwrap_err(); + assert!(error.to_string().contains("refusé")); + let error = to_data_url_in(&uploads, &temp, "/uploads/temp/../../../etc/passwd") + .await + .unwrap_err(); + assert!(error.to_string().contains("refusé")); + } + + #[tokio::test] + async fn laisse_les_url_externes_telles_quelles() { + let (uploads, temp) = dirs(); + assert_eq!( + to_data_url_in(&uploads, &temp, "https://exemple.fr/logo.png") + .await + .unwrap(), + "https://exemple.fr/logo.png" + ); + } + + #[tokio::test] + async fn convertit_un_logo_en_data_url() { + let (uploads, temp) = dirs(); + let logos = uploads.join("logos"); + std::fs::create_dir_all(&logos).unwrap(); + std::fs::write(logos.join("logo.png"), b"abc").unwrap(); + assert_eq!( + to_data_url_in(&uploads, &temp, "/uploads/logos/logo.png") + .await + .unwrap(), + "data:image/png;base64,YWJj" + ); + // Fichier absent : l'URL est rendue telle quelle + assert_eq!( + to_data_url_in(&uploads, &temp, "/uploads/logos/absent.png") + .await + .unwrap(), + "/uploads/logos/absent.png" + ); + let _ = std::fs::remove_dir_all(uploads.parent().unwrap()); + } + + #[test] + fn normalisation_lexicale() { + assert_eq!( + normalize(Path::new("/a/b/../c/./d")), + PathBuf::from("/a/c/d") + ); + assert_eq!(normalize(Path::new("/a/../../etc")), PathBuf::from("/etc")); + } +} diff --git a/server/src/services/reels/tts_sync.rs b/server/src/services/reels/tts_sync.rs new file mode 100644 index 0000000..b6863a1 --- /dev/null +++ b/server/src/services/reels/tts_sync.rs @@ -0,0 +1,109 @@ +//! Estimation de la durée de lecture d'un texte, affichée pendant la saisie +//! (portage de `server/services/ttsSync.ts`). +//! +//! Le minutage réel des mots vient de la voix au moment du rendu ; ici, une +//! estimation suffit et ne déclenche aucune synthèse (payante avec Gemini). + +use once_cell::sync::Lazy; +use regex::Regex; +use serde::Serialize; + +/// Débit moyen d'une voix de synthèse française, en mots par seconde. +const WORDS_PER_SECOND: f64 = 2.6; +/// Au-delà, la voix dépasse la durée confortable d'un Reel. +const MAX_COMFORTABLE_SECONDS: f64 = 45.0; +const PUNCTUATION_PAUSE: f64 = 0.35; + +static HASHTAG: Lazy = + Lazy::new(|| Regex::new(r"#[A-Za-z0-9_\x{C0}-\x{FF}]+").expect("regex")); +static LINK: Lazy = Lazy::new(|| Regex::new(r"https?://\S+").expect("regex")); +static WORDLIKE: Lazy = + Lazy::new(|| Regex::new(r"[A-Za-z0-9\x{C0}-\x{FF}]").expect("regex")); +static PAUSE: Lazy = Lazy::new(|| Regex::new(r"[.!?;:]").expect("regex")); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncTiming { + pub word_duration: f64, + pub audio_duration: f64, + pub word_count: usize, + pub punctuation_pause: f64, + pub is_healthy: bool, + pub warnings: Vec, +} + +/// `Math.round` de JavaScript (les demis arrondis vers le haut). +fn js_round(value: f64) -> f64 { + (value + 0.5).floor() +} + +pub fn estimate_voice_timing(text: &str) -> SyncTiming { + let without_tags = HASHTAG.replace_all(text, ""); + let cleaned = LINK.replace_all(&without_tags, ""); + let word_count = cleaned + .split(char::is_whitespace) + .filter(|w| WORDLIKE.is_match(w)) + .count(); + let pauses = PAUSE.find_iter(text).count(); + let audio_duration = word_count as f64 / WORDS_PER_SECOND + pauses as f64 * PUNCTUATION_PAUSE; + + let mut warnings = Vec::new(); + if audio_duration > MAX_COMFORTABLE_SECONDS { + warnings.push(format!( + "Texte long : environ {} s de voix. Visez moins de {} s.", + js_round(audio_duration), + MAX_COMFORTABLE_SECONDS + )); + } + if word_count > 0 && word_count < 4 { + warnings.push("Texte très court : la voix ne durera que quelques secondes.".into()); + } + + SyncTiming { + word_duration: if word_count > 0 { + audio_duration / word_count as f64 + } else { + 0.0 + }, + audio_duration, + word_count, + punctuation_pause: PUNCTUATION_PAUSE, + is_healthy: warnings.is_empty(), + warnings, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignore_hashtags_et_liens() { + assert_eq!( + estimate_voice_timing("Venez vite #promo https://x.fr").word_count, + 2 + ); + } + + #[test] + fn previent_quand_la_voix_sera_trop_longue() { + let timing = estimate_voice_timing(&"mot ".repeat(200)); + assert!(!timing.is_healthy); + assert!(timing.warnings[0].starts_with("Texte long")); + assert_eq!( + timing.warnings[0], + "Texte long : environ 77 s de voix. Visez moins de 45 s." + ); + } + + #[test] + fn texte_court() { + let timing = estimate_voice_timing("Bonjour !"); + assert_eq!(timing.word_count, 1); + assert_eq!( + timing.warnings, + vec!["Texte très court : la voix ne durera que quelques secondes."] + ); + assert!((timing.audio_duration - (1.0 / 2.6 + 0.35)).abs() < 1e-9); + } +} From efbd6dde39dd32eb7e88426e6e7014aa5288912b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:37:13 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Serveur=20Rust=20:=20TikTok,=20planificat?= =?UTF-8?q?eur,=20t=C3=A2ches=20p=C3=A9riodiques,=20API=20externe=20et=20p?= =?UTF-8?q?ages=20l=C3=A9gales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/tiktok : OAuth, renouvellement des jetons, creator_info, publication par chunks (FILE_UPLOAD) et suivi asynchrone des statuts - services/scheduler : publication des posts dus chaque minute (Facebook via facebook::publish_post, TikTok, rejet des anciens « both ») - services/cron : suivi TikTok (*/2), jetons (0,12 h), statistiques (8,20 h), purge des médias (3 h) et purge horaire des sessions, en UTC - services/media_purge : images > 30 jours, vidéos > 7 jours - routes/tiktok (/api/tiktok), routes/external (/api/v1, clé X-API-Key, messages de validation identiques à zod), routes/legal (/terms, /privacy, HTML identique à l'octet près) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- server/src/routes/external.rs | 1074 ++++++++++++++++++++++++- server/src/routes/legal.rs | 368 ++++++++- server/src/routes/tiktok.rs | 444 +++++++++- server/src/services/cron.rs | 308 ++++++- server/src/services/media_purge.rs | 157 +++- server/src/services/scheduler.rs | 258 +++++- server/src/services/tiktok/mod.rs | 630 ++++++++++++++- server/src/services/tiktok/payload.rs | 536 ++++++++++++ 8 files changed, 3749 insertions(+), 26 deletions(-) create mode 100644 server/src/services/tiktok/payload.rs diff --git a/server/src/routes/external.rs b/server/src/routes/external.rs index 94c1729..b041676 100644 --- a/server/src/routes/external.rs +++ b/server/src/routes/external.rs @@ -1,9 +1,1079 @@ -//! STUB — routes à porter (voir `server/routes/external.ts`). +//! API externe (`/api/v1`) utilisée par des outils tiers (n8n…), protégée par +//! la clé du header `X-API-Key`. -use axum::Router; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::Duration; +use axum::extract::{FromRequestParts, Path, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use base64::Engine; +use bytes::Bytes; +use regex::Regex; +use serde_json::{json, Map, Value}; + +use crate::db::{MediaType, NewMedia, NewPost, NewScheduledPost, Patch, PostStatus, PostType, Val}; +use crate::error::AppError; +use crate::extract::{Body, QueryMap}; +use crate::services::media_store::{self, MediaSource}; +use crate::services::tiktok::error_message; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + .route("/publish", post(publish)) + .route("/pages", get(list_pages)) + .route("/posts", get(list_posts)) + .route("/posts/{id}", delete(delete_post).patch(update_post)) +} + +// ---------------------------------------------------------------------------- +// Authentification par clé API +// ---------------------------------------------------------------------------- + +fn json_error(status: StatusCode, message: &str) -> Response { + (status, Json(json!({ "error": message }))).into_response() +} + +/// Comparaison à temps constant des clés. +fn keys_match(provided: &str, expected: &str) -> bool { + let (a, b) = (provided.as_bytes(), expected.as_bytes()); + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Extracteur : la requête porte la clé de l'API externe (header `X-API-Key`), +/// comparée à `app_config.external_api_key`, à défaut à `EXTERNAL_API_KEY`. +/// Placé en premier paramètre des routes, il est vérifié avant tout le reste. +pub struct ApiKey; + +impl FromRequestParts for ApiKey { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let provided = parts + .headers + .get("x-api-key") + .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned()) + .filter(|v| !v.is_empty()); + + let Some(provided) = provided else { + return Err(json_error( + StatusCode::UNAUTHORIZED, + "Clé API manquante (header X-API-Key requis)", + )); + }; + + let config = match state.db.get_app_config().await { + Ok(config) => config, + Err(error) => { + tracing::error!("[apiKey middleware] Error: {error}"); + return Err(json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur interne lors de la vérification de la clé API", + )); + } + }; + + let key = config + .and_then(|c| c.external_api_key) + .filter(|k| !k.is_empty()) + .or_else(|| state.config.external_api_key.clone()); + + match key { + None => Err(json_error( + StatusCode::SERVICE_UNAVAILABLE, + "API externe non configurée", + )), + Some(key) if !keys_match(&provided, &key) => { + Err(json_error(StatusCode::UNAUTHORIZED, "Clé API invalide")) + } + Some(_) => Ok(ApiKey), + } + } +} + +// ---------------------------------------------------------------------------- +// Erreurs des routes +// ---------------------------------------------------------------------------- + +/// Échec d'une route : réponse déjà formée, ou erreur imprévue (→ 500). +enum Failure { + Api(AppError), + Other(anyhow::Error), +} + +impl From for Failure { + fn from(error: AppError) -> Self { + Failure::Api(error) + } +} + +impl From for Failure { + fn from(error: anyhow::Error) -> Self { + Failure::Other(error) + } +} + +impl From for Failure { + fn from(error: sqlx::Error) -> Self { + Failure::Other(error.into()) + } +} + +impl Failure { + /// 500 avec le message de la cause (`error.message`). + fn with_cause(self, log: &str) -> Response { + match self { + Failure::Api(error) => error.into_response(), + Failure::Other(error) => { + tracing::error!("{log} {error:#}"); + json_error(StatusCode::INTERNAL_SERVER_ERROR, &error_message(&error)) + } + } + } + + /// 500 avec un message générique. + fn generic(self, log: &str) -> Response { + match self { + Failure::Api(error) => error.into_response(), + Failure::Other(error) => { + tracing::error!("{log} {error:#}"); + json_error(StatusCode::INTERNAL_SERVER_ERROR, "Erreur interne") + } + } + } +} + +type Outcome = Result; + +// ---------------------------------------------------------------------------- +// Validation de POST /publish (schéma zod de l'ancien serveur) +// ---------------------------------------------------------------------------- + +const POST_TYPES: [&str; 3] = ["feed", "story", "both"]; + +/// `z.string().datetime({ offset: true })` (même expression que zod 3.24). +static DATETIME_RE: LazyLock = LazyLock::new(|| { + let date = "(([0-9][0-9][2468][048]|[0-9][0-9][13579][26]|[0-9][0-9]0[48]|[02468][048]00|[13579][26]00)-02-29|[0-9]{4}-((0[13578]|1[02])-(0[1-9]|[12][0-9]|3[01])|(0[469]|11)-(0[1-9]|[12][0-9]|30)|(02)-(0[1-9]|1[0-9]|2[0-8])))"; + let time = "([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?"; + Regex::new(&format!("^{date}T{time}(Z|([+-][0-9]{{2}}:?[0-9]{{2}}))$")) + .expect("expression de date valide") +}); + +#[derive(Debug, Clone, PartialEq)] +struct PublishInput { + content: String, + image_url: Option, + page_ids: Vec, + scheduled_at: Option, + post_type: PostType, + user_id: Option, +} + +/// Type d'une valeur JSON tel que le nomme zod. +fn zod_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn type_issue(expected: &str, value: Option<&Value>) -> String { + match value { + None => "Required".to_string(), + Some(value) => format!("Expected {expected}, received {}", zod_type(value)), + } +} + +/// Valide le corps comme `publishSchema.safeParse` ; en cas d'échec, renvoie +/// `error.flatten().fieldErrors`. +fn validate_publish(body: &Value) -> Result { + let mut errors: Vec<(&'static str, String)> = Vec::new(); + let Some(obj) = body.as_object() else { + // Erreur globale : aucun champ en cause + return Err(Value::Object(Map::new())); + }; + + // content : z.string().min(1) + let content = match obj.get("content") { + Some(Value::String(s)) => { + if s.is_empty() { + errors.push(("content", "Le contenu est requis".into())); + } + Some(s.clone()) + } + other => { + errors.push(("content", type_issue("string", other))); + None + } + }; + + // imageUrl : z.string().url().optional() + let image_url = match obj.get("imageUrl") { + None => None, + Some(Value::String(s)) => { + if url::Url::parse(s).is_err() { + errors.push(("imageUrl", "URL d'image invalide".into())); + } + Some(s.clone()) + } + Some(other) => { + errors.push(("imageUrl", type_issue("string", Some(other)))); + None + } + }; + + // pageIds : z.array(z.string()).min(1) + let page_ids = match obj.get("pageIds") { + Some(Value::Array(items)) => { + if items.is_empty() { + errors.push(("pageIds", "Au moins une page est requise".into())); + } + let mut ids = Vec::new(); + for item in items { + match item { + Value::String(s) => ids.push(s.clone()), + other => errors.push(("pageIds", type_issue("string", Some(other)))), + } + } + ids + } + other => { + errors.push(("pageIds", type_issue("array", other))); + Vec::new() + } + }; + + // scheduledAt : z.string().datetime({ offset: true }).optional() + let scheduled_at = match obj.get("scheduledAt") { + None => None, + Some(Value::String(s)) => { + if !DATETIME_RE.is_match(s) { + errors.push(("scheduledAt", "Invalid datetime".into())); + } + Some(s.clone()) + } + Some(other) => { + errors.push(("scheduledAt", type_issue("string", Some(other)))); + None + } + }; + + // postType : z.enum(["feed", "story", "both"]).default("feed") + let expected_types = "'feed' | 'story' | 'both'"; + let post_type = match obj.get("postType") { + None => PostType::Feed, + Some(Value::String(s)) if POST_TYPES.contains(&s.as_str()) => { + PostType::parse(s).unwrap_or(PostType::Feed) + } + Some(Value::String(s)) => { + errors.push(( + "postType", + format!("Invalid enum value. Expected {expected_types}, received '{s}'"), + )); + PostType::Feed + } + Some(other) => { + errors.push(("postType", type_issue(expected_types, Some(other)))); + PostType::Feed + } + }; + + // userId : z.string().optional() + let user_id = match obj.get("userId") { + None => None, + Some(Value::String(s)) => Some(s.clone()), + Some(other) => { + errors.push(("userId", type_issue("string", Some(other)))); + None + } + }; + + if !errors.is_empty() { + let mut details = Map::new(); + for (field, message) in errors { + details + .entry(field) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("tableau de messages") + .push(Value::String(message)); + } + return Err(Value::Object(details)); + } + + Ok(PublishInput { + content: content.unwrap_or_default(), + image_url, + page_ids, + scheduled_at, + post_type, + user_id, + }) +} + +// ---------------------------------------------------------------------------- +// Téléchargement d'une image distante +// ---------------------------------------------------------------------------- + +/// Taille maximale d'une image téléchargée : 10 Mo. +const MAX_IMAGE_SIZE: usize = 10 * 1024 * 1024; + +/// Délai d'obtention de la réponse (en-têtes). +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10); + +fn image_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "image/jpeg" => Some(".jpg"), + "image/png" => Some(".png"), + "image/webp" => Some(".webp"), + "image/gif" => Some(".gif"), + _ => None, + } +} + +/// Type MIME d'un en-tête `Content-Type` (sans paramètres). +fn mime_of(content_type: &str) -> String { + content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_string() +} + +struct DownloadedImage { + buffer: Bytes, + ext: &'static str, +} + +/// Contenu d'une URL `data:` (acceptée par `fetch` côté Node). +fn decode_data_url(url: &str) -> Option<(String, Bytes)> { + let rest = url.strip_prefix("data:")?; + let (meta, data) = rest.split_once(',')?; + let (mime, is_base64) = match meta.strip_suffix(";base64") { + Some(mime) => (mime, true), + None => (meta, false), + }; + let bytes: Vec = percent_encoding::percent_decode_str(data).collect(); + let bytes = if is_base64 { + let cleaned: Vec = bytes + .into_iter() + .filter(|b| !b.is_ascii_whitespace()) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .ok()? + } else { + bytes + }; + let mime = if mime.is_empty() { + "text/plain;charset=US-ASCII".to_string() + } else { + mime.to_string() + }; + Some((mime, Bytes::from(bytes))) +} + +fn check_image(content_type: &str, buffer: Bytes) -> anyhow::Result { + let mime_type = mime_of(content_type); + let Some(ext) = image_extension(&mime_type) else { + anyhow::bail!( + "Type MIME non supporté: {mime_type}. Formats acceptés: JPEG, PNG, WebP, GIF" + ); + }; + if buffer.len() > MAX_IMAGE_SIZE { + anyhow::bail!( + "Image trop volumineuse ({:.1} MB, max 10 MB)", + buffer.len() as f64 / 1024.0 / 1024.0 + ); + } + Ok(DownloadedImage { buffer, ext }) +} + +async fn download_image(state: &AppState, url: &str) -> anyhow::Result { + let parsed = + url::Url::parse(url).map_err(|_| anyhow::anyhow!("Failed to parse URL from {url}"))?; + + if parsed.scheme() == "data" { + let (mime, buffer) = decode_data_url(url).ok_or_else(|| anyhow::anyhow!("fetch failed"))?; + return check_image(&mime, buffer); + } + + let response = match tokio::time::timeout(DOWNLOAD_TIMEOUT, state.http.get(parsed).send()).await + { + Err(_) => anyhow::bail!("This operation was aborted"), + Ok(Err(error)) => { + tracing::warn!("[external API] Téléchargement impossible de {url}: {error}"); + anyhow::bail!("fetch failed"); + } + Ok(Ok(response)) => response, + }; + + if !response.status().is_success() { + anyhow::bail!( + "Impossible de télécharger l'image (HTTP {})", + response.status().as_u16() + ); + } + + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + // Le type est contrôlé avant de lire le corps, comme l'ancien serveur + if image_extension(&mime_of(&content_type)).is_none() { + return check_image(&content_type, Bytes::new()); + } + let buffer = response.bytes().await.map_err(|error| { + tracing::warn!("[external API] Lecture impossible de {url}: {error}"); + anyhow::anyhow!("fetch failed") + })?; + check_image(&content_type, buffer) +} + +/// Télécharge une image et l'enregistre comme média du propriétaire. +async fn import_image( + state: &AppState, + url: &str, + owner_id: &str, + prefix: &str, +) -> anyhow::Result { + let image = download_image(state, url).await?; + let file_name = format!( + "{prefix}-{}{}", + chrono::Utc::now().timestamp_millis(), + image.ext + ); + let uploaded = + media_store::upload_media(state, MediaSource::Bytes(&image.buffer), &file_name).await?; + let media = state + .db + .create_media(NewMedia { + user_id: owner_id.to_string(), + media_type: Some(MediaType::Image), + cloudinary_public_id: Some(uploaded.public_id), + original_url: uploaded.original_url, + facebook_feed_url: uploaded.facebook_feed_url, + instagram_feed_url: uploaded.instagram_feed_url, + instagram_story_url: uploaded.instagram_story_url, + file_name, + file_size: image.buffer.len() as i32, + ..Default::default() + }) + .await?; + Ok(media) +} + +// ---------------------------------------------------------------------------- +// POST /api/v1/publish +// ---------------------------------------------------------------------------- + +/// Crée une publication (avec image optionnelle téléchargée depuis une URL) +/// et la programme pour une ou plusieurs pages. +async fn publish(_key: ApiKey, State(state): State, Body(body): Body) -> Response { + match publish_inner(&state, &body).await { + Ok(response) => response, + Err(failure) => failure.with_cause("[external API] Error:"), + } +} + +async fn publish_inner(state: &AppState, body: &Value) -> Outcome { + let input = match validate_publish(body) { + Ok(input) => input, + Err(details) => { + return Err(AppError::json( + StatusCode::BAD_REQUEST, + json!({ "error": "Données invalides", "details": details }), + ) + .into()) + } + }; + + // Résoudre l'utilisateur propriétaire + let owner_id = match input.user_id.as_deref().filter(|id| !id.is_empty()) { + None => { + let users = state.db.get_all_users().await?; + match users.into_iter().find(|u| u.is_admin()) { + Some(admin) => admin.id, + None => return Err(AppError::internal("Aucun utilisateur admin trouvé").into()), + } + } + Some(user_id) => { + if state.db.get_user(user_id).await?.is_none() { + return Err( + AppError::bad_request(format!("Utilisateur introuvable: {user_id}")).into(), + ); + } + user_id.to_string() + } + }; + + // Vérifier que toutes les pages existent + let mut resolved_pages: Vec<(String, String)> = Vec::new(); + for page_id in &input.page_ids { + match state.db.get_social_page(page_id).await? { + Some(page) => resolved_pages.push((page.id, page.page_name)), + None => { + return Err(AppError::bad_request(format!("Page introuvable: {page_id}")).into()) + } + } + } + + // Validation story → image obligatoire + let needs_image = matches!(input.post_type, PostType::Story | PostType::Both); + if needs_image && input.image_url.as_deref().is_none_or(str::is_empty) { + return Err( + AppError::bad_request("Les stories nécessitent une image (imageUrl requis)").into(), + ); + } + + // Télécharger l'image si fournie + let media = match input.image_url.as_deref().filter(|u| !u.is_empty()) { + Some(url) => Some(import_image(state, url, &owner_id, "external").await?), + None => None, + }; + + // Créer le post (heure de publication absente = immédiat) + let scheduled_at = match &input.scheduled_at { + Some(value) => Ts::parse(value) + .and_then(|ts| Ts::from_millis(ts.timestamp_millis())) + .ok_or_else(|| anyhow::anyhow!("Invalid time value"))?, + None => Ts::now(), + }; + + let mut new_post = NewPost::new(owner_id.clone(), input.content.clone()); + new_post.status = PostStatus::Scheduled; + new_post.scheduled_for = Some(scheduled_at); + let post = state.db.create_post(new_post).await?; + + // Lier le média si présent + if let Some(media) = &media { + state + .db + .set_post_media(&post.id, &[(media.id.clone(), 0)], false) + .await?; + } + + // Créer les scheduled_posts par page + let mut created = Vec::new(); + for (page_id, page_name) in &resolved_pages { + let types: &[PostType] = if input.post_type == PostType::Both { + &[PostType::Story, PostType::Feed] + } else { + std::slice::from_ref(&input.post_type) + }; + for post_type in types { + let scheduled = state + .db + .create_scheduled_post(NewScheduledPost::new( + post.id.clone(), + page_id.clone(), + *post_type, + scheduled_at, + )) + .await?; + created.push(json!({ + "pageId": page_id, + "pageName": page_name, + "scheduledPostId": scheduled.id, + })); + } + } + + Ok(( + StatusCode::CREATED, + Json(json!({ + "success": true, + "post": { + "id": post.id, + "content": post.content, + "status": post.status, + "scheduledAt": scheduled_at, + "postType": input.post_type, + "pages": created, + "media": media.map(|m| json!({ "id": m.id, "url": m.original_url })), + }, + })), + ) + .into_response()) +} + +// ---------------------------------------------------------------------------- +// GET /api/v1/pages +// ---------------------------------------------------------------------------- + +/// Liste les pages disponibles (id + nom + plateforme), pour connaître les +/// `pageIds` à passer à /publish. +async fn list_pages(_key: ApiKey, State(state): State) -> Response { + let result: Outcome = async { + let users = state.db.get_all_users().await?; + let Some(admin) = users.into_iter().find(|u| u.is_admin()) else { + return Err(AppError::internal("Aucun utilisateur admin trouvé").into()); + }; + let pages = state.db.get_social_pages(&admin.id).await?; + let list: Vec = pages + .iter() + .map(|p| { + json!({ + "id": p.id, + "pageName": p.page_name, + "platform": p.platform, + "pageId": p.page_id, + "tokenStatus": p.token_status, + }) + }) + .collect(); + Ok(Json(list).into_response()) + } + .await; + result.unwrap_or_else(|f| f.generic("[external API] Error listing pages:")) +} + +// ---------------------------------------------------------------------------- +// GET /api/v1/posts +// ---------------------------------------------------------------------------- + +/// Médias d'un post, dans l'ordre d'affichage : `{ id, url, type }`. +async fn post_media_items(state: &AppState, post_id: &str) -> Result, sqlx::Error> { + let links = state.db.get_post_media(post_id).await?; + let ids: Vec = links.iter().map(|l| l.media_id.clone()).collect(); + let by_id: HashMap = state + .db + .get_media_by_ids(&ids) + .await? + .into_iter() + .map(|m| (m.id.clone(), m)) + .collect(); + Ok(links + .iter() + .filter_map(|l| by_id.get(&l.media_id)) + .map(|m| json!({ "id": m.id, "url": m.original_url, "type": m.media_type })) + .collect()) +} + +/// Liste les publications planifiées (à venir). +/// +/// Paramètres : `pageId`, `from`, `to` (ISO 8601), `status` (défaut : scheduled, ou `all`). +async fn list_posts(_key: ApiKey, State(state): State, query: QueryMap) -> Response { + list_posts_inner(&state, &query) + .await + .unwrap_or_else(|f| f.generic("[external API] GET /posts error:")) +} + +async fn list_posts_inner(state: &AppState, query: &QueryMap) -> Outcome { + let post_status = query.non_empty("status").unwrap_or("scheduled"); + + // Toutes les pages, ou une seule si filtrée + let page_ids: Vec = match query.non_empty("pageId") { + Some(page_id) => { + if state.db.get_social_page(page_id).await?.is_none() { + return Err(AppError::not_found(format!("Page introuvable: {page_id}")).into()); + } + vec![page_id.to_string()] + } + None => state + .db + .get_all_social_pages() + .await? + .into_iter() + .map(|p| p.id) + .collect(), + }; + + if page_ids.is_empty() { + return Ok(Json(json!([])).into_response()); + } + + // Une date illisible faisait échouer la requête (`Invalid time value`) + let parse_bound = |key: &str| -> Result, Failure> { + match query.non_empty(key) { + None => Ok(None), + Some(value) => Ts::parse(value) + .map(Some) + .ok_or_else(|| anyhow::anyhow!("Invalid time value").into()), + } + }; + let start = parse_bound("from")?; + let end = parse_bound("to")?; + + let scheduled_posts = state + .db + .get_scheduled_posts_by_pages(&page_ids, start, end) + .await?; + + // Grouper par post, en ne gardant que les posts non publiés au bon statut + let mut order: Vec = Vec::new(); + let mut posts: HashMap = HashMap::new(); + + for sp in scheduled_posts { + if sp.scheduled.published_at.is_some() { + continue; + } + let post = &sp.post; + if post.status.as_str() != post_status && post_status != "all" { + continue; + } + + if !posts.contains_key(&post.id) { + let media = post_media_items(state, &post.id).await?; + order.push(post.id.clone()); + posts.insert( + post.id.clone(), + json!({ + "id": post.id, + "content": post.content, + "status": post.status, + "scheduledFor": post.scheduled_for, + "createdAt": post.created_at, + "media": media, + "schedules": [], + }), + ); + } + + let schedule = json!({ + "id": sp.scheduled.id, + "pageId": sp.scheduled.page_id, + "pageName": sp.page.as_ref().map(|p| p.page_name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Inconnu".into()), + "platform": sp.page.as_ref().map(|p| p.platform.as_str()).unwrap_or("facebook"), + "postType": sp.scheduled.post_type, + "scheduledAt": sp.scheduled.scheduled_at, + }); + if let Some(Value::Array(schedules)) = + posts.get_mut(&post.id).and_then(|p| p.get_mut("schedules")) + { + schedules.push(schedule); + } + } + + let list: Vec = order.iter().filter_map(|id| posts.remove(id)).collect(); + Ok(Json(list).into_response()) +} + +// ---------------------------------------------------------------------------- +// PATCH /api/v1/posts/:id +// ---------------------------------------------------------------------------- + +/// Valeur JSON écrite dans une colonne texte, comme le faisait node-postgres. +fn text_value(value: &Value) -> Val { + match value { + Value::Null => Val::Text(None), + Value::String(s) => Val::Text(Some(s.clone())), + Value::Number(n) => Val::Text(Some(n.to_string())), + Value::Bool(b) => Val::Text(Some(b.to_string())), + other => Val::Text(Some(other.to_string())), + } +} + +/// `new Date(value)` ; `None` pour une date invalide. +fn js_new_date(value: &Value) -> Option { + match value { + Value::Null => Ts::from_millis(0), + Value::Bool(b) => Ts::from_millis(i64::from(*b)), + Value::Number(n) => n.as_f64().and_then(|f| Ts::from_millis(f.trunc() as i64)), + Value::String(s) => Ts::parse(s).and_then(|ts| Ts::from_millis(ts.timestamp_millis())), + _ => None, + } +} + +/// Texte d'une valeur passée à `fetch()` (`String(value)`). +fn js_url_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Null => "null".into(), + Value::Object(_) => "[object Object]".into(), + Value::Array(items) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => js_url_text(other), + }) + .collect::>() + .join(","), + other => other.to_string(), + } +} + +/// Planifications non publiées d'un post, avec la page. +async fn pending_schedules(state: &AppState, post_id: &str) -> Result, sqlx::Error> { + let mut schedules = Vec::new(); + for sp in state.db.get_scheduled_posts_by_post(post_id).await? { + if sp.published_at.is_some() { + continue; + } + let page = state.db.get_social_page(&sp.page_id).await?; + schedules.push(json!({ + "id": sp.id, + "pageId": sp.page_id, + "pageName": page.as_ref().map(|p| p.page_name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Inconnu".into()), + "platform": page.as_ref().map(|p| p.platform.as_str()).unwrap_or("facebook"), + "postType": sp.post_type, + "scheduledAt": sp.scheduled_at, + })); + } + Ok(schedules) +} + +/// Modifie une publication planifiée : `content`, `scheduledAt`, `imageUrl`. +async fn update_post( + _key: ApiKey, + State(state): State, + Path(id): Path, + Body(body): Body, +) -> Response { + update_post_inner(&state, &id, &body) + .await + .unwrap_or_else(|f| f.with_cause("[external API] PATCH /posts/:id error:")) +} + +async fn update_post_inner(state: &AppState, id: &str, body: &Value) -> Outcome { + // Vérifier que le post existe et est modifiable + let Some(post) = state.db.get_post(id).await? else { + return Err(AppError::not_found("Post introuvable").into()); + }; + if post.status == PostStatus::Published { + return Err(AppError::bad_request("Impossible de modifier un post déjà publié").into()); + } + + // Mettre à jour le contenu si fourni + if let Some(content) = body.get("content") { + state + .db + .update_post(id, Patch::new().set("content", text_value(content))) + .await?; + } + + // Mettre à jour la date de planification si fournie + if let Some(value) = body.get("scheduledAt") { + let new_date = js_new_date(value).ok_or_else(|| anyhow::anyhow!("Invalid time value"))?; + state + .db + .update_post(id, Patch::new().set("scheduled_for", new_date)) + .await?; + + // Propager à tous les scheduled_posts du post + for sp in state.db.get_scheduled_posts_by_post(id).await? { + if sp.published_at.is_none() { + state + .db + .update_scheduled_post(&sp.id, Patch::new().set("scheduled_at", new_date)) + .await?; + } + } + } + + // Remplacer le média si une nouvelle imageUrl est fournie + if let Some(image_url) = body.get("imageUrl") { + let media = import_image( + state, + &js_url_text(image_url), + &post.user_id, + "external-edit", + ) + .await?; + state.db.update_post_media(id, &[media.id]).await?; + } + + // Retourner le post mis à jour + let updated = state + .db + .get_post(id) + .await? + .ok_or_else(|| anyhow::anyhow!("Cannot read properties of undefined (reading 'id')"))?; + + let mut media_items = Vec::new(); + for link in state.db.get_post_media(id).await? { + if let Some(item) = state.db.get_media_by_id(&link.media_id).await? { + media_items + .push(json!({ "id": item.id, "url": item.original_url, "type": item.media_type })); + } + } + + let schedules = pending_schedules(state, id).await?; + + Ok(Json(json!({ + "success": true, + "post": { + "id": updated.id, + "content": updated.content, + "status": updated.status, + "scheduledFor": updated.scheduled_for, + "createdAt": updated.created_at, + "media": media_items, + "schedules": schedules, + }, + })) + .into_response()) +} + +// ---------------------------------------------------------------------------- +// DELETE /api/v1/posts/:id +// ---------------------------------------------------------------------------- + +/// Supprime un post planifié ; la cascade supprime ses planifications et liens médias. +async fn delete_post( + _key: ApiKey, + State(state): State, + Path(id): Path, +) -> Response { + let result: Outcome = async { + let Some(post) = state.db.get_post(&id).await? else { + return Err(AppError::not_found("Post introuvable").into()); + }; + if post.status == PostStatus::Published { + return Err( + AppError::bad_request("Impossible de supprimer un post déjà publié").into(), + ); + } + state.db.delete_post(&id).await?; + Ok(Json(json!({ "success": true, "deleted": id })).into_response()) + } + .await; + result.unwrap_or_else(|f| f.generic("[external API] DELETE /posts/:id error:")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publish_validation_success_and_defaults() { + let input = validate_publish(&json!({ + "content": "Promo", + "pageIds": ["p1"], + "scheduledAt": "2026-05-01T10:30:00+02:00", + "extra": true, + })) + .unwrap(); + assert_eq!(input.post_type, PostType::Feed); + assert_eq!(input.page_ids, vec!["p1".to_string()]); + assert_eq!( + input.scheduled_at.as_deref(), + Some("2026-05-01T10:30:00+02:00") + ); + } + + #[test] + fn publish_validation_field_errors() { + let details = validate_publish(&json!({})).unwrap_err(); + assert_eq!( + details, + json!({ "content": ["Required"], "pageIds": ["Required"] }) + ); + + let details = validate_publish(&json!({ + "content": "", + "imageUrl": "pas une url", + "pageIds": [], + "scheduledAt": "2026-05-01 10:30", + "postType": "reel", + "userId": 4, + })) + .unwrap_err(); + assert_eq!( + details, + json!({ + "content": ["Le contenu est requis"], + "imageUrl": ["URL d'image invalide"], + "pageIds": ["Au moins une page est requise"], + "scheduledAt": ["Invalid datetime"], + "postType": ["Invalid enum value. Expected 'feed' | 'story' | 'both', received 'reel'"], + "userId": ["Expected string, received number"], + }) + ); + + let details = validate_publish(&json!({ + "content": 3, "pageIds": ["a", 1, null], "postType": 2, "imageUrl": null + })) + .unwrap_err(); + assert_eq!( + details, + json!({ + "content": ["Expected string, received number"], + "imageUrl": ["Expected string, received null"], + "pageIds": ["Expected string, received number", "Expected string, received null"], + "postType": ["Expected 'feed' | 'story' | 'both', received number"], + }) + ); + + assert_eq!(validate_publish(&json!([])).unwrap_err(), json!({})); + } + + #[test] + fn datetime_regex_matches_zod() { + for ok in [ + "2026-05-01T10:30:00Z", + "2026-05-01T10:30:00.123Z", + "2026-05-01T10:30:00+02:00", + "2026-05-01T10:30:00+0200", + "2024-02-29T00:00:00Z", + ] { + assert!(DATETIME_RE.is_match(ok), "{ok}"); + } + for bad in [ + "2026-05-01T10:30Z", + "2026-05-01T10:30:00", + "2023-02-29T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-05-01 10:30:00Z", + "2026-05-01T10:30:00Z", + ] { + assert!(!DATETIME_RE.is_match(bad), "{bad}"); + } + } + + #[test] + fn image_checks() { + assert_eq!(mime_of("image/png; charset=binary"), "image/png"); + assert!(check_image("image/png", Bytes::from_static(b"x")).is_ok()); + assert_eq!( + check_image("text/html; charset=utf-8", Bytes::new()) + .err() + .unwrap() + .to_string(), + "Type MIME non supporté: text/html. Formats acceptés: JPEG, PNG, WebP, GIF" + ); + let big = Bytes::from(vec![0u8; MAX_IMAGE_SIZE + 1]); + assert_eq!( + check_image("image/jpeg", big).err().unwrap().to_string(), + "Image trop volumineuse (10.0 MB, max 10 MB)" + ); + let (mime, data) = decode_data_url("data:image/png;base64,aGVsbG8=").unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!(&data[..], b"hello"); + } + + #[test] + fn js_conversions() { + assert_eq!( + js_new_date(&json!(null)).unwrap().to_iso(), + "1970-01-01T00:00:00.000Z" + ); + assert_eq!( + js_new_date(&json!("2026-05-01T10:30:00.123456+02:00")) + .unwrap() + .to_iso(), + "2026-05-01T08:30:00.123Z" + ); + assert!(js_new_date(&json!("demain")).is_none()); + assert!(js_new_date(&json!({})).is_none()); + assert_eq!(js_url_text(&json!(null)), "null"); + assert_eq!(js_url_text(&json!(12)), "12"); + assert!(keys_match("abc", "abc")); + assert!(!keys_match("abc", "abd")); + assert!(!keys_match("abc", "abcd")); + } } diff --git a/server/src/routes/legal.rs b/server/src/routes/legal.rs index 84d5731..a304356 100644 --- a/server/src/routes/legal.rs +++ b/server/src/routes/legal.rs @@ -1,9 +1,375 @@ -//! STUB — routes à porter (voir `server/routes/legal.ts`). +//! Pages légales publiques (conditions d'utilisation et politique de confidentialité). +//! +//! TikTok exige une URL publique pour chacune lors de la création de l'application +//! développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont +//! servies en HTML depuis le serveur, sans authentification, pour rester lisibles +//! même par un robot qui n'exécute pas le JavaScript du client. +//! +//! Les mentions de l'exploitant sont préremplies avec l'identité de la société +//! exploitante et restent surchargeables par variables d'environnement : +//! LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM, +//! LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL +use std::sync::LazyLock; + +use axum::http::header::CONTENT_TYPE; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; use axum::Router; +use regex::{Captures, Regex}; +use crate::config::Config; use crate::state::AppState; pub fn router() -> Router { Router::new() + .route("/terms", get(terms)) + .route("/privacy", get(privacy)) +} + +struct CompanyInfo { + name: String, + trade_name: String, + legal_form: String, + rcs: String, + address: String, + email: String, +} + +fn company_info() -> CompanyInfo { + let var = |name: &str, default: &str| Config::var(name).unwrap_or_else(|| default.to_string()); + CompanyInfo { + name: var("LEGAL_COMPANY_NAME", "FROUARD DISTRIBUTION"), + trade_name: var("LEGAL_TRADE_NAME", "LA FOIR'FOUILLE"), + legal_form: var( + "LEGAL_COMPANY_LEGAL_FORM", + "Société par actions simplifiée au capital de 50 000 €", + ), + rcs: var("LEGAL_COMPANY_RCS", "478 693 518 R.C.S. Nancy"), + address: var( + "LEGAL_COMPANY_ADDRESS", + "13 rue du Saule Gaillard, 54390 Frouard", + ), + // Aucune adresse de contact ne figure au registre : elle doit être fournie, + // le RGPD imposant un point de contact pour l'exercice des droits. + email: var( + "LEGAL_CONTACT_EMAIL", + "[Adresse e-mail de contact à renseigner]", + ), + } +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +static PLACEHOLDER: LazyLock = + LazyLock::new(|| Regex::new("%([A-Z_]+)%").expect("expression valide")); + +/// Remplit les marqueurs `%NOM%` d'un gabarit en une seule passe (les valeurs +/// insérées ne sont jamais réinterprétées). +fn fill(template: &str, values: &[(&str, &str)]) -> String { + PLACEHOLDER + .replace_all(template, |caps: &Captures| { + let key = &caps[1]; + values + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.to_string()) + .unwrap_or_else(|| caps[0].to_string()) + }) + .into_owned() +} + +/// Mentions de l'exploitant, échappées, pour les gabarits. +fn company_values(company: &CompanyInfo) -> Vec<(&'static str, String)> { + vec![ + ("NAME", escape_html(&company.name)), + ("TRADE_NAME", escape_html(&company.trade_name)), + ("LEGAL_FORM", escape_html(&company.legal_form)), + ("ADDRESS", escape_html(&company.address)), + ("RCS", escape_html(&company.rcs)), + ("EMAIL", escape_html(&company.email)), + ] +} + +fn render(title: &str, body_template: &str) -> String { + let company = company_info(); + let mut values = company_values(&company); + let footer = { + let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); + fill(FOOTER_TEMPLATE, &refs) + }; + values.push(("FOOTER", footer)); + let body = { + let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); + fill(body_template, &refs) + }; + fill( + PAGE_TEMPLATE, + &[("TITLE", &escape_html(title)), ("BODY", &body)], + ) +} + +fn html(content: String) -> Response { + ([(CONTENT_TYPE, "text/html; charset=utf-8")], content).into_response() +} + +async fn terms() -> Response { + html(render("Conditions d'utilisation", TERMS_TEMPLATE)) +} + +async fn privacy() -> Response { + html(render("Politique de confidentialité", PRIVACY_TEMPLATE)) +} + +// ---------------------------------------------------------------------------- +// Gabarits (repris à l'identique de l'ancien serveur) +// ---------------------------------------------------------------------------- + +const PAGE_TEMPLATE: &str = r###" + + + + + %TITLE% + + + +%BODY% + +"###; + +const FOOTER_TEMPLATE: &str = r###""###; + +const TERMS_TEMPLATE: &str = r###" +

Conditions d'utilisation

+

Dernière mise à jour : 11 août 2026

+ +

+ Cette application est un outil interne de gestion des réseaux sociaux exploité par + %NAME%, %LEGAL_FORM%, dont le siège est situé + %ADDRESS%, immatriculée au %RCS%, qui exploite le + magasin sous l'enseigne %TRADE_NAME% (« nous »). Elle permet à nos équipes + de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes + et pages officiels de nos magasins. +

+ +

1. Accès au service

+

+ L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte + nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des + actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment. +

+ +

2. Comptes de réseaux sociaux connectés

+

+ L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous + sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des + droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis + l'application, ou par le retrait de l'autorisation directement dans les réglages de la + plateforme concernée. +

+ +

3. Contenus publiés

+

+ Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus + doivent respecter la loi applicable ainsi que les règles des plateformes de destination, + notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta. + Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits, + y compris pour les images et les musiques utilisées. +

+ +

4. Disponibilité

+

+ Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication, + hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son + fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective + d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles. +

+ +

5. Responsabilité

+

+ Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des + dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une + publication manquée ou la suspension d'un compte par une plateforme tierce. +

+ +

6. Modification des conditions

+

+ Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page. +

+ +

7. Contact

+

Pour toute question relative à ces conditions : %EMAIL%

+ + %FOOTER%"###; + +const PRIVACY_TEMPLATE: &str = r###" +

Politique de confidentialité

+

Dernière mise à jour : 11 août 2026

+ +

+ Cette politique décrit les données traitées par notre outil interne de gestion des réseaux + sociaux. Le responsable de traitement est %NAME%, + %LEGAL_FORM%, dont le siège est situé %ADDRESS%, + immatriculée au %RCS%, exploitant le magasin sous l'enseigne + %TRADE_NAME%. +

+ +

1. Données de nos utilisateurs internes

+

+ Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion, + un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a + accès. Ces données servent uniquement à l'authentification et à la gestion des droits. +

+ +

2. Données issues des comptes de réseaux sociaux connectés

+

+ Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du + titulaire du compte et dans le cadre du périmètre user.info.basic : +

+
    +
  • l'identifiant technique du compte (open_id) ;
  • +
  • le nom d'affichage du compte ;
  • +
  • l'image de profil du compte ;
  • +
  • un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.
  • +
+

+ Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni + la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces + comptes. +

+ +

3. Finalités

+

+ Ces données sont utilisées exclusivement pour identifier le compte de destination dans + l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à + l'heure programmée. Elles ne servent à aucune autre finalité. +

+ +

4. Données que nous transmettons aux plateformes

+

+ Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image, + le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de + confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la + politique de confidentialité de la plateforme concernée. +

+ +

5. Conservation et sécurité

+
    +
  • Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).
  • +
  • + Ils sont conservés tant que le compte reste connecté à l'application, et supprimés + immédiatement lorsque le compte en est retiré. +
  • +
  • Les médias sources sont supprimés automatiquement après leur publication et leur péremption.
  • +
  • L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.
  • +
+ +

6. Partage avec des tiers

+

+ Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins + publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination + (TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos + prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions. +

+ +

7. Retirer une autorisation

+

+ Le titulaire d'un compte connecté peut retirer son autorisation à tout moment : +

+
    +
  • en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;
  • +
  • + ou directement dans TikTok, via Profil › Paramètres et confidentialité › + Sécurité et autorisations › Gérer les autorisations des applications. +
  • +
+

+ Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la + suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis + celle-ci. +

+ +

8. Vos droits

+

+ Conformément au Règlement général sur la protection des données, vous disposez d'un droit + d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous + concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces + droits, écrivez à %EMAIL%. +

+ +

9. Modification de cette politique

+

+ Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page. +

+ + %FOOTER%"###; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escaping_and_single_pass_fill() { + assert_eq!( + escape_html(r#"A & "c""#), + "A & <b> "c"" + ); + assert_eq!( + fill("%A%-%B%-%C%", &[("A", "%B%"), ("B", "x")]), + "%B%-x-%C%" + ); + } + + #[test] + fn pages_are_complete() { + let page = render("Conditions d'utilisation", TERMS_TEMPLATE); + assert!(page.starts_with("\n")); + assert!(page.contains("Conditions d'utilisation")); + assert!(page.contains("enseigne LA FOIR'FOUILLE
")); + assert!(page.ends_with("\n\n")); + assert!(!page.contains('%')); + let page = render("Politique de confidentialité", PRIVACY_TEMPLATE); + assert!(page.contains("

Politique de confidentialité

")); + assert!(!page.contains("%NAME%")); + } } diff --git a/server/src/routes/tiktok.rs b/server/src/routes/tiktok.rs index 9da1d31..3553d16 100644 --- a/server/src/routes/tiktok.rs +++ b/server/src/routes/tiktok.rs @@ -1,9 +1,449 @@ -//! STUB — routes à porter (voir `server/routes/tiktok.ts`). +//! Routes de gestion des comptes TikTok (montées sous `/api/tiktok`, connexion requise). +//! +//! Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la +//! saisie manuelle d'un token), puis vit dans `social_pages` comme une page +//! Facebook : mêmes permissions, même planification, même calendrier. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header::{CONTENT_TYPE, LOCATION}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use rand::RngCore; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::config::Config; +use crate::db::{NewSocialPage, Patch, Platform}; +use crate::error::{ApiResult, AppError}; +use crate::extract::{Body, QueryMap}; +use crate::services::tiktok; use crate::state::AppState; +/// Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. +const RETURN_PATH: &str = "/pages"; + +/// Clé de session de l'état OAuth (protection CSRF). +const OAUTH_STATE_KEY: &str = "tiktokOAuthState"; + pub fn router() -> Router { Router::new() + .route("/config", get(get_config).put(put_config)) + .route("/connect", get(connect)) + .route("/callback", get(callback)) + .route("/accounts/{id}/creator-info", get(creator_info)) +} + +fn build_return_url(success: bool, message: &str) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("tiktok", if success { "success" } else { "error" }) + .append_pair("message", message) + .finish(); + format!("{RETURN_PATH}?{query}") +} + +/// Redirection 302, comme `res.redirect()` d'Express. +fn redirect(location: &str) -> Response { + ( + StatusCode::FOUND, + [ + (LOCATION, location.to_string()), + (CONTENT_TYPE, "text/plain; charset=utf-8".to_string()), + ], + format!("Found. Redirecting to {location}"), + ) + .into_response() +} + +fn redirect_error(message: &str) -> Response { + redirect(&build_return_url(false, message)) +} + +/// État de la configuration TikTok. +/// Le client secret n'est jamais renvoyé au client. +async fn get_config( + State(state): State, + headers: HeaderMap, + user: AuthUser, +) -> ApiResult> { + let config = state.db.get_tiktok_config().await.map_err(|e| { + tracing::error!("❌ [TikTok] Erreur lecture configuration: {e}"); + AppError::internal("Impossible de lire la configuration TikTok") + })?; + let configured = tiktok::is_configured(&state).await; + + // Tout le monde a besoin de savoir si la connexion est possible ; seuls les + // admins voient les détails de l'application développeur. + if !user.is_admin() { + return Ok(Json(json!({ "configured": configured }))); + } + + let client_key = config + .as_ref() + .map(|c| c.client_key.clone()) + .filter(|k| !k.is_empty()) + .unwrap_or_else(|| { + if Config::var("TIKTOK_CLIENT_KEY").is_some() { + "(défini par variable d'environnement)".to_string() + } else { + String::new() + } + }); + let has_client_secret = config.as_ref().is_some_and(|c| !c.client_secret.is_empty()) + || Config::var("TIKTOK_CLIENT_SECRET").is_some(); + + Ok(Json(json!({ + "configured": configured, + "clientKey": client_key, + "hasClientSecret": has_client_secret, + "redirectUri": tiktok::redirect_uri(&state, Some(&headers)), + }))) +} + +/// Type d'une valeur JSON tel que le nomme zod (`Expected string, received …`). +fn zod_type(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +/// `z.string().trim().min(1, message)` : valeur rognée, ou message d'erreur zod. +fn required_trimmed(value: Option<&Value>, empty_message: &str) -> Result { + match value { + None => Err("Required".to_string()), + Some(Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Err(empty_message.to_string()) + } else { + Ok(trimmed.to_string()) + } + } + Some(other) => Err(format!( + "Expected string, received {}", + zod_type(Some(other)) + )), + } +} + +/// Validation du corps de `PUT /config` (schémas `insertTiktokConfigSchema` / +/// `updateTiktokConfigSchema`) : renvoie le premier message d'erreur, comme la route. +fn validate_config( + body: &Value, + secret_required: bool, +) -> Result<(String, Option), String> { + if !body.is_object() { + return Err(format!( + "Expected object, received {}", + zod_type(Some(body)) + )); + } + let client_key = + required_trimmed(body.get("clientKey"), "Le client key ne peut pas être vide")?; + let secret_message = "Le client secret ne peut pas être vide"; + let client_secret = match body.get("clientSecret") { + // Le secret peut être omis lors d'une mise à jour : on garde l'existant + None if !secret_required => None, + value => Some(required_trimmed(value, secret_message)?), + }; + Ok((client_key, client_secret)) +} + +/// Enregistre les identifiants de l'application développeur TikTok (admin). +async fn put_config( + State(state): State, + headers: HeaderMap, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden( + "Accès refusé. Réservé aux administrateurs.", + )); + } + + let invalid = |message: String| { + tracing::error!("❌ [TikTok] Erreur enregistrement configuration: {message}"); + AppError::bad_request(if message.is_empty() { + "Configuration TikTok invalide".to_string() + } else { + message + }) + }; + + let existing = state + .db + .get_tiktok_config() + .await + .map_err(|e| invalid(tiktok::error_message(&e.into())))?; + let (client_key, client_secret) = + validate_config(&body, existing.is_none()).map_err(invalid)?; + + let config = state + .db + .upsert_tiktok_config(Some(&client_key), client_secret.as_deref()) + .await + .map_err(|e| invalid(tiktok::error_message(&e)))?; + + Ok(Json(json!({ + "success": true, + "clientKey": config.client_key, + "redirectUri": tiktok::redirect_uri(&state, Some(&headers)), + }))) +} + +/// Démarre la connexion d'un compte TikTok. +/// À appeler par navigation complète du navigateur (window.location), pas en fetch. +async fn connect(State(state): State, headers: HeaderMap, user: AuthUser) -> Response { + if !tiktok::is_configured(&state).await { + return redirect_error( + "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.", + ); + } + + // Protection CSRF : l'état est vérifié au retour + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let oauth_state = hex::encode(bytes); + let mut session = user.session; + session.set(OAUTH_STATE_KEY, Value::String(oauth_state.clone())); + + let auth_url = match tiktok::build_authorization_url(&state, &oauth_state, Some(&headers)).await + { + Ok(url) => url, + Err(error) => { + tracing::error!("❌ [TikTok] Erreur démarrage OAuth: {error:#}"); + return redirect_error(&tiktok::error_message(&error)); + } + }; + + // La session doit être écrite avant la redirection, sinon l'état est perdu + if let Err(error) = session.save(&state).await { + tracing::error!("❌ [TikTok] Impossible de sauvegarder la session OAuth: {error}"); + return redirect_error("Erreur de session, réessayez."); + } + redirect(&auth_url) +} + +/// Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le +/// compte dans social_pages. +async fn callback( + State(state): State, + headers: HeaderMap, + user: AuthUser, + query: QueryMap, +) -> Response { + match handle_callback(&state, &headers, user, &query).await { + Ok(response) => response, + Err(error) => { + tracing::error!("❌ [TikTok] Erreur callback OAuth: {error:#}"); + redirect_error(&tiktok::error_message(&error)) + } + } +} + +async fn handle_callback( + state: &AppState, + headers: &HeaderMap, + user: AuthUser, + query: &QueryMap, +) -> anyhow::Result { + if let Some(oauth_error) = query.non_empty("error") { + let description = query.non_empty("error_description").unwrap_or(oauth_error); + return Ok(redirect_error(&format!( + "Autorisation TikTok refusée : {description}" + ))); + } + + let AuthUser { user, mut session } = user; + let expected_state = session.get_str(OAUTH_STATE_KEY); + if session.remove(OAUTH_STATE_KEY).is_some() { + if let Err(error) = session.save(state).await { + tracing::warn!("⚠️ [TikTok] Session non enregistrée après le retour OAuth: {error}"); + } + } + + let received_state = query.non_empty("state"); + let state_ok = matches!( + (received_state, expected_state.as_deref()), + (Some(received), Some(expected)) if !expected.is_empty() && received == expected + ); + if !state_ok { + return Ok(redirect_error( + "Requête de connexion TikTok invalide (état non reconnu).", + )); + } + + let Some(code) = query.non_empty("code") else { + return Ok(redirect_error("Aucun code d'autorisation reçu de TikTok.")); + }; + + let tokens = tiktok::exchange_code_for_token(state, code, Some(headers)).await?; + let profile = tiktok::get_user_info(state, &tokens.access_token).await?; + let open_id = profile + .open_id + .clone() + .or_else(|| tokens.open_id.clone().filter(|id| !id.is_empty())) + .ok_or_else(|| { + anyhow::anyhow!("Réponse TikTok inattendue : identifiant du compte absent") + })?; + + // Un compte déjà connecté est mis à jour plutôt que dupliqué + let existing_pages = state.db.get_social_pages(&user.id).await?; + let existing = existing_pages + .iter() + .find(|p| p.platform == Platform::Tiktok && p.page_id == open_id); + + if let Some(existing) = existing { + state + .db + .update_social_page( + &existing.id, + Patch::new() + .set("page_name", profile.display_name.clone()) + .set("avatar_url", profile.avatar_url.clone()) + .set("is_active", "true"), + ) + .await?; + tiktok::persist_tokens(state, &existing.id, &tokens).await?; + + tracing::info!("🔁 [TikTok] Compte reconnecté: {}", profile.display_name); + return Ok(redirect(&build_return_url( + true, + &format!("Compte TikTok \"{}\" reconnecté.", profile.display_name), + ))); + } + + let (token_expires_at, refresh_token_expires_at) = tiktok::token_expirations(&tokens); + let page = state + .db + .create_social_page(NewSocialPage { + user_id: user.id.clone(), + platform: Some(Platform::Tiktok), + page_id: open_id.clone(), + page_name: profile.display_name.clone(), + access_token: tokens.access_token.clone(), + token_expires_at, + refresh_token: tokens.refresh_token.clone(), + refresh_token_expires_at, + scopes: tokens.scope.clone(), + avatar_url: profile.avatar_url.clone(), + is_active: Some("true".to_string()), + ..Default::default() + }) + .await?; + + // Les non-admins ne voient que les pages qui leur sont explicitement + // attribuées : sans cette permission, ils ne verraient pas leur propre compte. + if !user.is_admin() { + if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await { + tracing::error!("⚠️ [TikTok] Impossible de créer la permission de page: {error}"); + } + } + + tracing::info!( + "✅ [TikTok] Nouveau compte connecté: {} ({open_id})", + profile.display_name + ); + Ok(redirect(&build_return_url( + true, + &format!("Compte TikTok \"{}\" connecté.", profile.display_name), + ))) +} + +/// Options réellement autorisées sur un compte (confidentialité, commentaires, +/// duo, stitch, durée max). TikTok impose de les afficher avant publication. +async fn creator_info( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + let bad_gateway = |error: anyhow::Error| { + tracing::error!("❌ [TikTok] Erreur récupération creator info: {error:#}"); + AppError::json( + StatusCode::BAD_GATEWAY, + json!({ "error": tiktok::error_message(&error) }), + ) + }; + + let page = state + .db + .get_social_page(&id) + .await + .map_err(|e| bad_gateway(e.into()))?; + let Some(page) = page.filter(|p| p.platform == Platform::Tiktok) else { + return Err(AppError::not_found("Compte TikTok non trouvé")); + }; + + if !user.is_admin() && page.user_id != user.id { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .map_err(|e| bad_gateway(e.into()))?; + if !accessible.iter().any(|p| p.id == page.id) { + return Err(AppError::forbidden("Accès refusé à ce compte")); + } + } + + let info = tiktok::get_creator_info(&state, &page) + .await + .map_err(bad_gateway)?; + Ok(Json(serde_json::to_value(info).unwrap_or(Value::Null))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn return_url_is_url_encoded() { + assert_eq!( + build_return_url(true, "Compte TikTok \"Nancy\" connecté."), + "/pages?tiktok=success&message=Compte+TikTok+%22Nancy%22+connect%C3%A9." + ); + } + + #[test] + fn config_validation_matches_zod_messages() { + assert_eq!( + validate_config(&json!({ "clientKey": " k ", "clientSecret": " s " }), true), + Ok(("k".to_string(), Some("s".to_string()))) + ); + assert_eq!( + validate_config(&json!({ "clientKey": "k" }), true), + Err("Required".to_string()) + ); + assert_eq!( + validate_config(&json!({ "clientKey": "k" }), false), + Ok(("k".to_string(), None)) + ); + assert_eq!( + validate_config(&json!({ "clientKey": " " }), false), + Err("Le client key ne peut pas être vide".to_string()) + ); + assert_eq!( + validate_config(&json!({ "clientKey": "k", "clientSecret": "" }), false), + Err("Le client secret ne peut pas être vide".to_string()) + ); + assert_eq!( + validate_config(&json!({ "clientKey": 3 }), false), + Err("Expected string, received number".to_string()) + ); + assert_eq!( + validate_config(&json!({ "clientKey": "k", "clientSecret": null }), false), + Err("Expected string, received null".to_string()) + ); + assert_eq!( + validate_config(&json!([]), false), + Err("Expected object, received array".to_string()) + ); + } } diff --git a/server/src/services/cron.rs b/server/src/services/cron.rs index e891369..24ac516 100644 --- a/server/src/services/cron.rs +++ b/server/src/services/cron.rs @@ -1,8 +1,308 @@ -//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias). +//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias, +//! sessions expirées). //! -//! STUB — à porter depuis `server/cron.ts`. -#![allow(unused_variables)] +//! Les horaires sont ceux de l'ancien `cron.ts` (node-cron), évalués en UTC, +//! l'heure du conteneur. Une boucle se réveille au début de chaque minute et +//! lance les tâches dont l'expression correspond. +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use chrono::{DateTime, Duration, Timelike, Utc}; + +use crate::services::{analytics, media_purge, tiktok, token_manager}; use crate::state::AppState; -pub fn start(state: AppState) {} +/// Expression cron à cinq champs ; seuls les minutes et les heures peuvent +/// être restreintes (jour, mois et jour de semaine valent `*`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CronSpec { + minutes: u64, + hours: u32, +} + +/// Champ cron (`*`, `*/n`, `a`, `a-b`, `a-b/n`, listes séparées par des virgules) +/// en masque de bits sur `0..=max`. +fn parse_field(field: &str, max: u32) -> Option { + let mut mask = 0u64; + for item in field.split(',') { + let (range, step) = match item.split_once('/') { + Some((range, step)) => (range, step.parse::().ok().filter(|s| *s > 0)?), + None => (item, 1), + }; + let (start, end) = if range == "*" { + (0, max) + } else if let Some((a, b)) = range.split_once('-') { + (a.parse().ok()?, b.parse().ok()?) + } else { + let value: u32 = range.parse().ok()?; + // `5/10` : de 5 jusqu'au maximum, comme cron + (value, if item.contains('/') { max } else { value }) + }; + if start > end || end > max { + return None; + } + let mut value = start; + while value <= end { + mask |= 1 << value; + value += step; + } + } + Some(mask) +} + +impl CronSpec { + pub fn parse(expression: &str) -> Option { + let fields: Vec<&str> = expression.split_whitespace().collect(); + let [minute, hour, day, month, weekday] = fields.as_slice() else { + return None; + }; + if [day, month, weekday].iter().any(|f| **f != "*") { + return None; + } + Some(Self { + minutes: parse_field(minute, 59)?, + hours: u32::try_from(parse_field(hour, 23)?).ok()?, + }) + } + + pub fn matches(&self, minute: u32, hour: u32) -> bool { + minute < 60 + && hour < 24 + && self.minutes & (1 << minute) != 0 + && self.hours & (1 << hour) != 0 + } + + pub fn matches_time(&self, time: DateTime) -> bool { + self.matches(time.minute(), time.hour()) + } +} + +/// Début de la minute suivant `now`. +fn next_minute(now: DateTime) -> DateTime { + let truncated = now + .with_second(0) + .and_then(|t| t.with_nanosecond(0)) + .unwrap_or(now); + truncated + Duration::minutes(1) +} + +/// Attend le début de la minute suivante et renvoie l'instant visé. +pub async fn sleep_until_next_minute() -> DateTime { + let now = Utc::now(); + let next = next_minute(now); + if let Ok(wait) = (next - now).to_std() { + tokio::time::sleep(wait).await; + } + next +} + +/// Libère le verrou d'une tâche, même si elle panique. +struct RunningGuard(Arc); + +impl Drop for RunningGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } +} + +/// Lance une exécution de tâche, sauf si la précédente n'est pas terminée +/// (évite par exemple de publier deux fois le même post). +pub fn spawn_guarded(running: &Arc, name: &'static str, task: F) +where + F: Future + Send + 'static, +{ + if running.swap(true, Ordering::SeqCst) { + tracing::warn!("[Cron] {name} : exécution précédente encore en cours, passage ignoré."); + return; + } + let guard = RunningGuard(running.clone()); + tokio::spawn(async move { + let _guard = guard; + task.await; + }); +} + +type JobFn = fn(AppState) -> std::pin::Pin + Send>>; + +struct Job { + name: &'static str, + spec: CronSpec, + run: JobFn, + running: Arc, +} + +impl Job { + fn new(name: &'static str, expression: &str, run: JobFn) -> Self { + Self { + name, + spec: CronSpec::parse(expression).expect("expression cron valide"), + run, + running: Arc::new(AtomicBool::new(false)), + } + } +} + +async fn tiktok_status_sync(state: AppState) { + if let Err(error) = tiktok::sync_pending_publications(&state).await { + tracing::error!("[Cron] TikTok publish status sync failed: {error:#}"); + } +} + +async fn token_check(state: AppState) { + tracing::info!("[Cron] Running token check..."); + let results = token_manager::check_and_refresh_tokens(&state).await; + let renewed = results.iter().filter(|r| r.renewed).count(); + let failing = results + .iter() + .filter(|r| r.status == "expired" || r.status == "error") + .count(); + tracing::info!( + "[Cron] Token check done: {} contrôlés, {renewed} renouvelés, {failing} en échec.", + results.len() + ); +} + +async fn analytics_sync(state: AppState) { + tracing::info!("[Cron] Running analytics sync for all pages..."); + if let Err(error) = analytics::sync_all_pages(&state).await { + tracing::error!("[Cron] Analytics sync failed: {error:#}"); + } +} + +async fn media_purge_job(state: AppState) { + tracing::info!("[Cron] Running daily media purge check..."); + if let Err(error) = media_purge::purge_old_media(&state).await { + tracing::error!("[Cron] Media purge check failed: {error:#}"); + } +} + +async fn session_prune(state: AppState) { + crate::auth::prune_sessions(&state).await; +} + +pub fn start(state: AppState) { + let jobs = vec![ + // Toutes les 2 minutes — suivi des publications TikTok (API asynchrone) + Job::new("TikTok publish status", "*/2 * * * *", |s| { + Box::pin(tiktok_status_sync(s)) + }), + // Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons. + // Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour + // réduit d'autant la fenêtre pendant laquelle une publication échouerait. + Job::new("Token refresh", "0 0,12 * * *", |s| { + Box::pin(token_check(s)) + }), + // Deux fois par jour (08:00 et 20:00) — statistiques de toutes les pages + Job::new("Analytics sync", "0 8,20 * * *", |s| { + Box::pin(analytics_sync(s)) + }), + // Tous les jours à 03:00 — purge des anciens médias + Job::new("Media purge", "0 3 * * *", |s| Box::pin(media_purge_job(s))), + // Toutes les heures — suppression des sessions expirées + Job::new("Session cleanup", "0 * * * *", |s| { + Box::pin(session_prune(s)) + }), + ]; + + tracing::info!("[Cron] TikTok publish status job scheduled (*/2 * * * *)."); + tracing::info!("[Cron] Token refresh job scheduled (0 0,12 * * *)."); + tracing::info!("[Cron] Analytics sync job scheduled (0 8,20 * * *)."); + tracing::info!("[Cron] Media purge job scheduled (0 3 * * *)."); + tracing::info!("[Cron] Session cleanup job scheduled (0 * * * *)."); + + tokio::spawn(async move { + loop { + let tick = sleep_until_next_minute().await; + for job in &jobs { + if job.spec.matches_time(tick) { + spawn_guarded(&job.running, job.name, (job.run)(state.clone())); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn every_two_minutes() { + let spec = CronSpec::parse("*/2 * * * *").unwrap(); + assert!(spec.matches(0, 0)); + assert!(spec.matches(58, 13)); + assert!(!spec.matches(1, 0)); + assert!(!spec.matches(59, 23)); + } + + #[test] + fn fixed_hours() { + let spec = CronSpec::parse("0 0,12 * * *").unwrap(); + assert!(spec.matches(0, 0)); + assert!(spec.matches(0, 12)); + assert!(!spec.matches(1, 12)); + assert!(!spec.matches(0, 8)); + let spec = CronSpec::parse("0 8,20 * * *").unwrap(); + assert!(spec.matches(0, 20) && spec.matches(0, 8) && !spec.matches(0, 9)); + let spec = CronSpec::parse("0 3 * * *").unwrap(); + assert!(spec.matches(0, 3) && !spec.matches(30, 3) && !spec.matches(0, 15)); + let spec = CronSpec::parse("0 * * * *").unwrap(); + assert!((0..24).all(|h| spec.matches(0, h)) && !spec.matches(1, 5)); + let spec = CronSpec::parse("* * * * *").unwrap(); + assert!(spec.matches(17, 9)); + } + + #[test] + fn ranges_and_invalid_expressions() { + let spec = CronSpec::parse("10-20/5 1-3 * * *").unwrap(); + assert!( + spec.matches(15, 2) + && spec.matches(20, 3) + && !spec.matches(16, 2) + && !spec.matches(15, 4) + ); + assert!(CronSpec::parse("0 0 1 * *").is_none()); + assert!(CronSpec::parse("60 * * * *").is_none()); + assert!(CronSpec::parse("*/0 * * * *").is_none()); + assert!(CronSpec::parse("* * * *").is_none()); + } + + #[test] + fn next_minute_is_aligned() { + let now = Utc.with_ymd_and_hms(2026, 10, 12, 11, 59, 42).unwrap(); + assert_eq!( + next_minute(now), + Utc.with_ymd_and_hms(2026, 10, 12, 12, 0, 0).unwrap() + ); + let exact = Utc.with_ymd_and_hms(2026, 10, 12, 12, 0, 0).unwrap(); + assert_eq!( + next_minute(exact), + Utc.with_ymd_and_hms(2026, 10, 12, 12, 1, 0).unwrap() + ); + } + + #[tokio::test] + async fn guard_skips_overlapping_runs() { + let running = Arc::new(AtomicBool::new(false)); + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + spawn_guarded(&running, "test", async move { + let _ = rx.await; + }); + assert!(running.load(Ordering::SeqCst)); + // Deuxième lancement ignoré tant que le premier tourne + spawn_guarded(&running, "test", async { + panic!("ne doit pas s'exécuter") + }); + tx.send(()).unwrap(); + for _ in 0..100 { + if !running.load(Ordering::SeqCst) { + break; + } + tokio::task::yield_now().await; + } + assert!(!running.load(Ordering::SeqCst)); + } +} diff --git a/server/src/services/media_purge.rs b/server/src/services/media_purge.rs index 71714ec..2d14cb5 100644 --- a/server/src/services/media_purge.rs +++ b/server/src/services/media_purge.rs @@ -1,10 +1,157 @@ -//! Purge quotidienne des anciens médias. -//! -//! STUB — à porter depuis `server/services/media-purge.ts`. -#![allow(unused_variables)] +//! Purge quotidienne des anciens médias locaux, pour économiser l'espace disque : +//! - images de plus de 30 jours ; +//! - vidéos de plus de 7 jours. +use chrono::Duration; + +use crate::db::{Media, MediaType}; +use crate::services::media_store; use crate::state::AppState; +use crate::time::Ts; -pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> { +/// Ancienneté au-delà de laquelle une image est purgée. +const IMAGE_RETENTION_DAYS: i64 = 30; +/// Ancienneté au-delà de laquelle une vidéo est purgée. +const VIDEO_RETENTION_DAYS: i64 = 7; + +/// Médias d'un type créés avant `before`. +async fn media_older_than( + state: &AppState, + media_type: MediaType, + before: Ts, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM media WHERE type = $1 AND created_at < $2") + .bind(media_type) + .bind(before) + .fetch_all(&state.db.pool) + .await +} + +/// Supprime le fichier, la vignette et la ligne d'un média. +async fn purge_one(state: &AppState, item: &Media) -> anyhow::Result<()> { + if let Some(public_id) = item + .cloudinary_public_id + .as_deref() + .filter(|p| !p.is_empty()) + { + media_store::delete_media(state, public_id).await?; + } + // La vignette n'a plus de raison d'être une fois la ligne supprimée + if let Some(thumbnail) = item + .thumbnail_url + .as_deref() + .and_then(|t| t.strip_prefix("/uploads/")) + { + if let Err(error) = media_store::delete_media(state, thumbnail).await { + tracing::warn!( + "[Media Purge] Vignette non supprimée pour {}: {error}", + item.id + ); + } + } + state.db.delete_media(&item.id).await?; Ok(()) } + +async fn run(state: &AppState) -> anyhow::Result<()> { + let now = Ts::now(); + let thirty_days_ago = now.add(Duration::days(-IMAGE_RETENTION_DAYS)); + let seven_days_ago = now.add(Duration::days(-VIDEO_RETENTION_DAYS)); + + let old_images = media_older_than(state, MediaType::Image, thirty_days_ago).await?; + let old_videos = media_older_than(state, MediaType::Video, seven_days_ago).await?; + + if old_images.is_empty() && old_videos.is_empty() { + tracing::info!("[Media Purge] No old media found to purge."); + return Ok(()); + } + + tracing::info!( + "[Media Purge] Found {} old images (>30 days) and {} old videos (>7 days) to purge.", + old_images.len(), + old_videos.len() + ); + + let mut success_count = 0; + let mut error_count = 0; + + for item in old_images.iter().chain(old_videos.iter()) { + match purge_one(state, item).await { + Ok(()) => success_count += 1, + Err(error) => { + tracing::error!( + "[Media Purge] Failed to delete media {}: {error:#}", + item.id + ); + error_count += 1; + } + } + } + + tracing::info!( + "[Media Purge] Purge completed. Deleted: {success_count}, Failed: {error_count}." + ); + Ok(()) +} + +/// Purge les anciens médias. Ne renvoie jamais d'erreur : un échec global est +/// journalisé, comme dans l'ancien serveur. +pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> { + tracing::info!("[Media Purge] Starting automatic purge of old media..."); + if let Err(error) = run(state).await { + tracing::error!("[Media Purge] Fatal error during media purge process: {error:#}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test sur une vraie base : `DATABASE_URL=… cargo test -- --ignored purge`. + #[tokio::test] + #[ignore] + async fn purge_against_database() { + let config = crate::config::Config::from_env().expect("DATABASE_URL requis"); + crate::crypto::init(&config); + let pool = crate::db::connect(&config.database_url).await.unwrap(); + let state = AppState::new(config, pool).unwrap(); + let user: String = sqlx::query_scalar("SELECT id FROM users LIMIT 1") + .fetch_one(&state.db.pool) + .await + .unwrap(); + let rows = [ + ("purge_old_img", "image", 31), + ("purge_new_img", "image", 29), + ("purge_old_vid", "video", 8), + ("purge_new_vid", "video", 6), + ]; + for (id, kind, days) in rows { + sqlx::query( + "INSERT INTO media (id, user_id, type, cloudinary_public_id, original_url, file_name, file_size, created_at) + VALUES ($1, $2, $3::media_type, $4, '/uploads/x', 'x', 1, now() - make_interval(days => $5))", + ) + .bind(id) + .bind(&user) + .bind(kind) + .bind(format!("media/{id}.bin")) + .bind(days) + .execute(&state.db.pool) + .await + .unwrap(); + } + + purge_old_media(&state).await.unwrap(); + + let left: Vec = + sqlx::query_scalar("SELECT id FROM media WHERE id LIKE 'purge_%' ORDER BY id") + .fetch_all(&state.db.pool) + .await + .unwrap(); + assert_eq!(left, vec!["purge_new_img", "purge_new_vid"]); + sqlx::query("DELETE FROM media WHERE id LIKE 'purge_%'") + .execute(&state.db.pool) + .await + .unwrap(); + } +} diff --git a/server/src/services/scheduler.rs b/server/src/services/scheduler.rs index 4b5829e..5fd8470 100644 --- a/server/src/services/scheduler.rs +++ b/server/src/services/scheduler.rs @@ -1,8 +1,256 @@ -//! Publication des posts programmés (toutes les minutes). -//! -//! STUB — à porter depuis `server/services/scheduler.ts`. -#![allow(unused_variables)] +//! Publication des posts programmés : toutes les minutes, les publications +//! dues sont envoyées sur Facebook ou TikTok. +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::db::{ + Media, MediaType, Patch, Platform, Post, PostStatus, PostType, ScheduledPost, SocialPage, +}; +use crate::services::cron::{sleep_until_next_minute, spawn_guarded}; +use crate::services::tiktok::error_message; +use crate::services::{facebook, media_store, tiktok}; use crate::state::AppState; +use crate::time::Ts; -pub fn start(state: AppState) {} +/// Démarre la boucle de publication (au début de chaque minute). +pub fn start(state: AppState) { + let running = Arc::new(AtomicBool::new(false)); + tokio::spawn(async move { + loop { + sleep_until_next_minute().await; + let state = state.clone(); + spawn_guarded(&running, "Scheduler", async move { + process_pending_posts(&state).await; + }); + } + }); + + tracing::info!("Scheduler service started"); +} + +/// Traite les publications dues (une erreur n'interrompt pas les suivantes). +pub async fn process_pending_posts(state: &AppState) { + let pending = match state.db.get_pending_scheduled_posts().await { + Ok(pending) => pending, + Err(error) => { + tracing::error!("Error processing pending posts: {error}"); + return; + } + }; + + for scheduled_post in pending { + if let Err(error) = process_one(state, &scheduled_post).await { + tracing::error!("Error publishing post {}: {error:#}", scheduled_post.id); + let recorded = state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new().set("error", error_message(&error)), + ) + .await; + if let Err(error) = recorded { + tracing::error!("Error processing pending posts: {error}"); + return; + } + } + } +} + +async fn process_one(state: &AppState, scheduled_post: &ScheduledPost) -> anyhow::Result<()> { + // Les anciens posts « both » doivent avoir été scindés à la création + // (les nouveaux le sont automatiquement par /api/posts) + if scheduled_post.post_type == PostType::Both { + let error_msg = "Legacy postType \"both\" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts."; + tracing::error!( + "Rejecting and deleting legacy \"both\" post {}: {error_msg}", + scheduled_post.id + ); + + // Suppression plutôt que « publié », qui serait trompeur + state.db.delete_scheduled_post(&scheduled_post.id).await?; + + // Le post repasse en brouillon pour être reprogrammé + state + .db + .update_post( + &scheduled_post.post_id, + Patch::new().set("status", PostStatus::Draft), + ) + .await?; + return Ok(()); + } + + publish_post(state, scheduled_post).await +} + +async fn publish_post(state: &AppState, scheduled_post: &ScheduledPost) -> anyhow::Result<()> { + let post = state.db.get_post(&scheduled_post.post_id).await?; + let page = state.db.get_social_page(&scheduled_post.page_id).await?; + + let (Some(post), Some(page)) = (post, page) else { + anyhow::bail!("Post or page not found"); + }; + + tracing::info!( + "Publishing post {} to {} page {}", + post.id, + page.platform, + page.page_name + ); + + // Tous les médias du post (carrousels multi-photos) + let mut media_list: Vec = Vec::new(); + for link in state.db.get_post_media(&post.id).await? { + if let Some(media) = state.db.get_media_by_id(&link.media_id).await? { + media_list.push(media); + } + } + + // Une story sans média ne peut pas être publiée + if scheduled_post.post_type == PostType::Story && media_list.is_empty() { + let error_msg = "Les stories nécessitent un média. Ce post ne peut pas être publié."; + tracing::error!( + "Deleting invalid story post {} without media: {error_msg}", + scheduled_post.id + ); + + state.db.delete_scheduled_post(&scheduled_post.id).await?; + state + .db + .update_post(&post.id, Patch::new().set("status", PostStatus::Draft)) + .await?; + return Ok(()); + } + + match page.platform { + Platform::Facebook => { + let external_post_id = + facebook::publish_post(state, &post, &page, scheduled_post.post_type, &media_list) + .await?; + + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("external_post_id", external_post_id) + .set_null("error"), + ) + .await?; + } + Platform::Tiktok => { + let publish_id = publish_to_tiktok(state, &post, &page, &media_list).await?; + + // TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour + // ne pas republier à la minute suivante, et le poller de statut renseignera + // l'identifiant définitif du post. + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("publish_id", publish_id) + .set("publish_status", "PROCESSING_UPLOAD") + .set_null("error"), + ) + .await?; + } + Platform::Instagram => { + // La publication Instagram n'est pas encore implémentée + tracing::warn!( + "Instagram publishing not yet implemented for post {}", + post.id + ); + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new().set("error", "Instagram publishing not yet implemented"), + ) + .await?; + state + .db + .update_post(&post.id, Patch::new().set("status", PostStatus::Failed)) + .await?; + return Ok(()); + } + } + + state + .db + .update_post(&post.id, Patch::new().set("status", PostStatus::Published)) + .await?; + + // Les vidéos locales ne sont supprimées que si plus aucune publication + // planifiée non envoyée ne les utilise + for media in &media_list { + let Some(public_id) = media + .cloudinary_public_id + .as_deref() + .filter(|p| media.media_type == MediaType::Video && !p.is_empty()) + else { + continue; + }; + if let Err(error) = delete_video_if_unused(state, &post, media, public_id).await { + tracing::warn!( + "[Scheduler] Failed to delete local video file for media {}: {error:#}", + media.id + ); + } + } + + tracing::info!( + "Successfully published post {} to {} page {}", + post.id, + page.platform, + page.page_name + ); + Ok(()) +} + +async fn delete_video_if_unused( + state: &AppState, + post: &Post, + media: &Media, + public_id: &str, +) -> anyhow::Result<()> { + let remaining = state.db.get_scheduled_posts_by_post(&post.id).await?; + let has_pending = remaining.iter().any(|sp| sp.published_at.is_none()); + if has_pending { + tracing::info!( + "[Scheduler] Kept local video file for media {} (pending scheduled posts still exist)", + media.id + ); + } else { + media_store::delete_media(state, public_id).await?; + tracing::info!( + "[Scheduler] Deleted local video file for media {}", + media.id + ); + } + Ok(()) +} + +/// Envoie la vidéo d'un post planifié sur un compte TikTok. +/// TikTok ne sait pas programmer une publication : c'est notre planificateur qui +/// déclenche l'envoi à l'heure voulue. Renvoie le `publish_id` à suivre. +async fn publish_to_tiktok( + state: &AppState, + post: &Post, + page: &SocialPage, + media_list: &[Media], +) -> anyhow::Result { + let Some(video) = media_list.iter().find(|m| m.media_type == MediaType::Video) else { + anyhow::bail!( + "Le compte TikTok \"{}\" ne peut recevoir que des vidéos : ce post n'en contient pas.", + page.page_name + ); + }; + + let buffer = media_store::read_media_buffer(state, &video.original_url).await?; + + tiktok::publish_video_from_buffer(state, page, buffer, &post.content).await +} diff --git a/server/src/services/tiktok/mod.rs b/server/src/services/tiktok/mod.rs index a395933..1be9995 100644 --- a/server/src/services/tiktok/mod.rs +++ b/server/src/services/tiktok/mod.rs @@ -1,24 +1,640 @@ -//! Comptes TikTok : OAuth, renouvellement des jetons, publication de vidéos. +//! Service de publication TikTok (Content Posting API v2). //! -//! STUB — à porter depuis `server/services/tiktok.ts`. -#![allow(unused_variables)] +//! Différences structurantes avec Facebook, qui expliquent la forme de ce service : +//! - Pas de token collable à la main : chaque compte magasin passe par OAuth. +//! L'access token ne vit que 24h et doit être renouvelé avec le refresh token +//! (valable 1 an) avant chaque publication. +//! - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant +//! définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite. +//! - `creator_info/query` doit être appelé avant chaque publication pour connaître +//! les niveaux de confidentialité réellement autorisés sur le compte. +//! +//! Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de +//! confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du +//! compte). Le code s'adapte automatiquement à ce que le compte autorise. +mod payload; + +use axum::http::header::{CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE}; +use axum::http::HeaderMap; use bytes::Bytes; +use chrono::Duration; +use serde_json::{json, Value}; -use crate::db::SocialPage; +use payload::{ + build_init_body, chunk_range, compute_chunking, decode_uri_component, js_slice, js_string, + parse_api_payload, parse_token_payload, resolve_privacy_level, +}; +pub use payload::{CreatorInfo, PublishOptions, PublishStatus, TokenResponse}; + +use crate::config::Config; +use crate::db::{Patch, Platform, SocialPage, TokenStatus}; +use crate::public_url::resolve_public_base_url; use crate::state::AppState; +use crate::time::Ts; -/// Envoie une vidéo à TikTok ; renvoie le `publish_id` (publication asynchrone). +const AUTH_BASE_URL: &str = "https://www.tiktok.com/v2/auth/authorize/"; +const API_BASE_URL: &str = "https://open.tiktokapis.com/v2"; + +/// Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. +const DEFAULT_SCOPES: &str = "user.info.basic,video.publish"; + +/// Renouvelle le token si sa fin de validité est à moins de 5 minutes. +const TOKEN_REFRESH_MARGIN_MS: i64 = 5 * 60 * 1000; + +/// Abandon du suivi d'une publication au-delà de 24h. +const STATUS_TRACKING_LIMIT_MS: i64 = 24 * 60 * 60 * 1000; + +/// Message d'une erreur tel que l'exposait l'ancien serveur : pour une erreur +/// PostgreSQL, le seul message du serveur (comme `error.message` de node-postgres). +pub fn error_message(error: &anyhow::Error) -> String { + if let Some(sqlx::Error::Database(db)) = error.downcast_ref::() { + return db.message().to_string(); + } + error.to_string() +} + +/// Envoie une requête ; une erreur réseau donne « fetch failed », comme `fetch` +/// côté Node (la cause est journalisée). +pub async fn send(request: reqwest::RequestBuilder) -> anyhow::Result { + request.send().await.map_err(|e| { + tracing::warn!("⚠️ Requête HTTP en échec : {e}"); + anyhow::anyhow!("fetch failed") + }) +} + +/// Statut HTTP, succès et corps texte d'une réponse. +async fn read_response(response: reqwest::Response) -> anyhow::Result<(u16, bool, String)> { + let status = response.status(); + let raw = response.text().await.map_err(|e| { + tracing::warn!("⚠️ Lecture de la réponse TikTok impossible : {e}"); + anyhow::anyhow!("fetch failed") + })?; + Ok((status.as_u16(), status.is_success(), raw)) +} + +/// Identifiants de l'application développeur TikTok (globaux à l'instance). +/// La base prime sur les variables d'environnement. +async fn get_credentials(state: &AppState) -> anyhow::Result<(String, String)> { + let config = state.db.get_tiktok_config().await?; + + let client_key = config + .as_ref() + .map(|c| c.client_key.clone()) + .filter(|k| !k.is_empty()) + .or_else(|| Config::var("TIKTOK_CLIENT_KEY")); + let client_secret = config + .as_ref() + .map(|c| c.client_secret.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| Config::var("TIKTOK_CLIENT_SECRET")); + + match (client_key, client_secret) { + (Some(key), Some(secret)) => Ok((key, secret)), + _ => anyhow::bail!( + "TikTok n'est pas configuré. Renseignez le client key et le client secret \ + de votre application TikTok dans les paramètres." + ), + } +} + +pub async fn is_configured(state: &AppState) -> bool { + get_credentials(state).await.is_ok() +} + +/// URI de redirection OAuth. Les en-têtes servent de repli quand `APP_URL` +/// n'est pas défini : le domaine public est alors lu sur la requête plutôt que +/// remplacé par `http://localhost:5555`. +pub fn redirect_uri(state: &AppState, headers: Option<&HeaderMap>) -> String { + if let Some(uri) = Config::var("TIKTOK_REDIRECT_URI") { + return uri; + } + format!( + "{}/api/tiktok/callback", + resolve_public_base_url(state, headers) + ) +} + +/// URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin. +/// Chaque magasin répète ce flux : un compte = une ligne dans social_pages. +pub async fn build_authorization_url( + state: &AppState, + oauth_state: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (client_key, _) = get_credentials(state).await?; + let scopes = Config::var("TIKTOK_SCOPES").unwrap_or_else(|| DEFAULT_SCOPES.to_string()); + + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("client_key", &client_key) + .append_pair("scope", &scopes) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect_uri(state, headers)) + .append_pair("state", oauth_state) + .finish(); + + Ok(format!("{AUTH_BASE_URL}?{query}")) +} + +/// Échange le code d'autorisation contre un couple access/refresh token. +pub async fn exchange_code_for_token( + state: &AppState, + code: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (client_key, client_secret) = get_credentials(state).await?; + // TikTok renvoie le code URL-encodé dans la query string + let code = decode_uri_component(code)?; + let redirect = redirect_uri(state, headers); + + request_token( + state, + &[ + ("client_key", client_key.as_str()), + ("client_secret", client_secret.as_str()), + ("code", code.as_str()), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect.as_str()), + ], + ) + .await +} + +/// Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. +pub async fn refresh_access_token( + state: &AppState, + refresh_token: &str, +) -> anyhow::Result { + let (client_key, client_secret) = get_credentials(state).await?; + + request_token( + state, + &[ + ("client_key", client_key.as_str()), + ("client_secret", client_secret.as_str()), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ], + ) + .await +} + +async fn request_token(state: &AppState, form: &[(&str, &str)]) -> anyhow::Result { + let body = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(form) + .finish(); + + let response = send( + state + .http + .post(format!("{API_BASE_URL}/oauth/token/")) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(CACHE_CONTROL, "no-cache") + .body(body), + ) + .await?; + + let (status, ok, raw) = read_response(response).await?; + parse_token_payload(status, ok, &raw) +} + +/// Récupère un access token valide pour une page, en le renouvelant si besoin. +/// Le nouveau couple de tokens est persisté immédiatement : le refresh token +/// TikTok est à usage unique, le perdre déconnecterait le compte. +pub async fn get_valid_access_token(state: &AppState, page: &SocialPage) -> anyhow::Result { + if page.platform != Platform::Tiktok { + anyhow::bail!("Ce service ne gère que les comptes TikTok"); + } + + let expires_at = page + .token_expires_at + .map(|t| t.timestamp_millis()) + .unwrap_or(0); + let still_valid = expires_at - TOKEN_REFRESH_MARGIN_MS > Ts::now().timestamp_millis(); + + if still_valid && !page.access_token.is_empty() { + return Ok(page.access_token.clone()); + } + + let Some(refresh_token) = page.refresh_token.as_deref().filter(|t| !t.is_empty()) else { + anyhow::bail!( + "Le compte TikTok \"{}\" doit être reconnecté (aucun refresh token enregistré).", + page.page_name + ); + }; + + tracing::info!( + "🔄 [TikTok] Renouvellement du token pour {}", + page.page_name + ); + + let tokens = match refresh_access_token(state, refresh_token).await { + Ok(tokens) => tokens, + Err(error) => { + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("token_status", TokenStatus::Expired) + .set("last_token_check", Ts::now()), + ) + .await?; + anyhow::bail!( + "Impossible de renouveler le token TikTok de \"{}\". \ + Le compte doit être reconnecté. ({})", + page.page_name, + error_message(&error) + ); + } + }; + + persist_tokens(state, &page.id, &tokens).await?; + + Ok(tokens.access_token) +} + +/// Échéance « maintenant + n secondes ». +fn expires_in(now: Ts, seconds: f64) -> Ts { + now.add(Duration::milliseconds((seconds * 1000.0) as i64)) +} + +/// Enregistre un couple de tokens fraîchement obtenu sur une page. +pub async fn persist_tokens( + state: &AppState, + page_id: &str, + tokens: &TokenResponse, +) -> anyhow::Result<()> { + let now = Ts::now(); + // Un champ absent de la réponse laisse la colonne inchangée + let mut patch = Patch::new().set("access_token", tokens.access_token.clone()); + if let Some(seconds) = tokens.expires_in { + patch = patch.set("token_expires_at", expires_in(now, seconds)); + } + if let Some(refresh_token) = &tokens.refresh_token { + patch = patch.set("refresh_token", refresh_token.clone()); + } + if let Some(seconds) = tokens.refresh_expires_in { + patch = patch.set("refresh_token_expires_at", expires_in(now, seconds)); + } + if let Some(scope) = &tokens.scope { + patch = patch.set("scopes", scope.clone()); + } + let patch = patch + .set("token_status", TokenStatus::Valid) + .set("last_token_check", now); + + state.db.update_social_page(page_id, patch).await?; + Ok(()) +} + +/// Échéances d'un couple de tokens, pour la création d'une page. +pub fn token_expirations(tokens: &TokenResponse) -> (Option, Option) { + let now = Ts::now(); + ( + tokens.expires_in.map(|s| expires_in(now, s)), + tokens.refresh_expires_in.map(|s| expires_in(now, s)), + ) +} + +/// Profil public du compte, utilisé pour nommer la page à la connexion. +#[derive(Debug, Clone)] +pub struct UserProfile { + pub open_id: Option, + pub display_name: String, + pub avatar_url: Option, +} + +pub async fn get_user_info(state: &AppState, access_token: &str) -> anyhow::Result { + let response = send( + state + .http + .get(format!( + "{API_BASE_URL}/user/info/?fields=open_id%2Cdisplay_name%2Cavatar_url" + )) + .bearer_auth(access_token), + ) + .await?; + + let data = parse_api_response(response, "récupération du profil").await?; + let user = data.get("user").cloned().unwrap_or(Value::Null); + let text = |key: &str| user.get(key).and_then(js_string).filter(|s| !s.is_empty()); + + Ok(UserProfile { + open_id: text("open_id"), + display_name: text("display_name").unwrap_or_else(|| "Compte TikTok".to_string()), + avatar_url: text("avatar_url"), + }) +} + +/// Options réellement autorisées sur le compte (confidentialité, commentaires, +/// duo, stitch, durée max). TikTok impose cet appel avant chaque publication. +pub async fn get_creator_info(state: &AppState, page: &SocialPage) -> anyhow::Result { + let token = get_valid_access_token(state, page).await?; + query_creator_info(state, &token).await +} + +/// Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque +/// renouvellement, donc une même opération ne doit déclencher qu'un seul refresh. +async fn query_creator_info(state: &AppState, access_token: &str) -> anyhow::Result { + let response = send( + state + .http + .post(format!("{API_BASE_URL}/post/publish/creator_info/query/")) + .bearer_auth(access_token) + .header(CONTENT_TYPE, "application/json; charset=UTF-8"), + ) + .await?; + + let data = parse_api_response(response, "récupération des options du compte").await?; + Ok(CreatorInfo::from_data(&data)) +} + +/// Publie une vidéo avec les options par défaut ; renvoie le `publish_id` +/// (publication asynchrone, suivie par [`sync_pending_publications`]). 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") + publish_video_with_options(state, page, video, description, &PublishOptions::default()).await } -/// Interroge le statut des envois TikTok en attente et met à jour les publications. +/// Publie une vidéo sur un compte TikTok en trois temps : +/// 1. init — déclare la taille et le découpage, récupère publish_id + upload_url +/// 2. upload — envoie les données binaires par chunks +/// 3. (plus tard) status/fetch — le poller récupère l'identifiant du post +/// +/// On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette +/// seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok +/// et que les médias soient publiquement accessibles. +pub async fn publish_video_with_options( + state: &AppState, + page: &SocialPage, + video: Bytes, + caption: &str, + options: &PublishOptions, +) -> anyhow::Result { + if page.platform != Platform::Tiktok { + anyhow::bail!("Ce service ne gère que les comptes TikTok"); + } + + // Un seul renouvellement de token pour toute la publication + let access_token = get_valid_access_token(state, page).await?; + let creator_info = query_creator_info(state, &access_token).await?; + let privacy_level = resolve_privacy_level( + &creator_info, + options.privacy_level.as_deref(), + &page.page_name, + )?; + + let video_size = video.len() as i64; + let (chunk_size, total_chunk_count) = compute_chunking(video_size); + + tracing::info!( + "🎬 [TikTok] Publication en cours: compte={}, taille={:.2} Mo, chunks={}, confidentialite={}", + page.page_name, + video_size as f64 / 1024.0 / 1024.0, + total_chunk_count, + privacy_level + ); + + // ── Étape 1 : init ──────────────────────────────────────────────────────── + let init_body = build_init_body( + caption, + &privacy_level, + &creator_info, + options, + video_size, + chunk_size, + total_chunk_count, + ); + + let init_response = send( + state + .http + .post(format!("{API_BASE_URL}/post/publish/video/init/")) + .bearer_auth(&access_token) + .header(CONTENT_TYPE, "application/json; charset=UTF-8") + .body(init_body.to_string()), + ) + .await?; + + let init_data = parse_api_response(init_response, "initialisation de la publication").await?; + let publish_id = init_data + .get("publish_id") + .and_then(js_string) + .filter(|s| !s.is_empty()); + let upload_url = init_data + .get("upload_url") + .and_then(js_string) + .filter(|s| !s.is_empty()); + + let (Some(publish_id), Some(upload_url)) = (publish_id, upload_url) else { + anyhow::bail!("TikTok n'a pas renvoyé d'identifiant de publication"); + }; + + tracing::info!("📤 [TikTok] Upload initialisé: publish_id={publish_id}"); + + // ── Étape 2 : upload des chunks ─────────────────────────────────────────── + for index in 0..total_chunk_count { + let (start, end) = chunk_range(index, chunk_size, total_chunk_count, video_size); + let chunk = video.slice(start as usize..(end + 1).max(start) as usize); + + let upload_response = send( + state + .http + .put(&upload_url) + .header(CONTENT_TYPE, "video/mp4") + .header(CONTENT_RANGE, format!("bytes {start}-{end}/{video_size}")) + .body(chunk), + ) + .await?; + + if !upload_response.status().is_success() { + let status = upload_response.status().as_u16(); + let error_text = upload_response.text().await.unwrap_or_default(); + anyhow::bail!( + "Échec de l'envoi du chunk {}/{} vers TikTok ({}): {}", + index + 1, + total_chunk_count, + status, + js_slice(&error_text, 200) + ); + } + + tracing::info!( + "📤 [TikTok] Chunk {}/{} envoyé", + index + 1, + total_chunk_count + ); + } + + tracing::info!( + "✅ [TikTok] Vidéo transmise pour {}, traitement en cours côté TikTok", + page.page_name + ); + + Ok(publish_id) +} + +/// Interroge TikTok sur l'avancement d'une publication. +pub async fn get_publish_status( + state: &AppState, + page: &SocialPage, + publish_id: &str, +) -> anyhow::Result { + let access_token = get_valid_access_token(state, page).await?; + + let response = send( + state + .http + .post(format!("{API_BASE_URL}/post/publish/status/fetch/")) + .bearer_auth(&access_token) + .header(CONTENT_TYPE, "application/json; charset=UTF-8") + .body(json!({ "publish_id": publish_id }).to_string()), + ) + .await?; + + let data = parse_api_response(response, "suivi de la publication").await?; + Ok(PublishStatus::from_data(&data)) +} + +/// Met à jour les publications TikTok encore en cours de traitement. +/// Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post +/// (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo. pub async fn sync_pending_publications(state: &AppState) -> anyhow::Result<()> { + let pending = state + .db + .get_scheduled_posts_awaiting_publish_status() + .await?; + + if pending.is_empty() { + return Ok(()); + } + + tracing::info!( + "🔎 [TikTok] Suivi de {} publication(s) en cours...", + pending.len() + ); + + for scheduled_post in pending { + if let Err(error) = sync_one(state, &scheduled_post).await { + tracing::error!( + "⚠️ [TikTok] Impossible de récupérer le statut de la publication {}: {}", + scheduled_post.id, + error_message(&error) + ); + } + } + Ok(()) } + +async fn sync_one( + state: &AppState, + scheduled_post: &crate::db::ScheduledPost, +) -> anyhow::Result<()> { + let page = state.db.get_social_page(&scheduled_post.page_id).await?; + let (Some(page), Some(publish_id)) = (page, scheduled_post.publish_id.as_deref()) else { + return Ok(()); + }; + if page.platform != Platform::Tiktok || publish_id.is_empty() { + return Ok(()); + } + + let status = get_publish_status(state, &page, publish_id).await?; + let current = status.status.as_deref(); + + if current == Some("PUBLISH_COMPLETE") { + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("publish_status", "PUBLISH_COMPLETE") + // À défaut d'ID public (compte privé), on garde le publish_id comme référence + .set( + "external_post_id", + status + .post_id + .clone() + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| publish_id.to_string()), + ) + .set_null("error"), + ) + .await?; + tracing::info!("✅ [TikTok] Publication terminée sur {}", page.page_name); + return Ok(()); + } + + if current == Some("FAILED") { + let reason = status + .fail_reason + .clone() + .filter(|r| !r.is_empty()) + .unwrap_or_else(|| "raison inconnue".to_string()); + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("publish_status", "FAILED") + .set("error", format!("Publication TikTok échouée : {reason}")), + ) + .await?; + tracing::error!( + "❌ [TikTok] Publication échouée sur {}: {}", + page.page_name, + status.fail_reason.as_deref().unwrap_or("undefined") + ); + return Ok(()); + } + + // Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API + let sent_at = scheduled_post + .published_at + .map(|t| t.timestamp_millis()) + .unwrap_or(0); + if sent_at != 0 && Ts::now().timestamp_millis() - sent_at > STATUS_TRACKING_LIMIT_MS { + let shown = current.unwrap_or("undefined"); + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new().set("publish_status", "FAILED").set( + "error", + format!("Statut TikTok toujours \"{shown}\" après 24h, suivi abandonné."), + ), + ) + .await?; + tracing::error!( + "❌ [TikTok] Suivi abandonné pour {} (statut {shown})", + page.page_name + ); + return Ok(()); + } + + match current { + Some(current) => { + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new().set("publish_status", current), + ) + .await?; + } + // Statut absent de la réponse : rien à enregistrer, on réessaiera + None => anyhow::bail!("No values to set"), + } + + Ok(()) +} + +async fn parse_api_response(response: reqwest::Response, context: &str) -> anyhow::Result { + let (status, ok, raw) = read_response(response).await?; + parse_api_payload(status, ok, &raw, context) +} diff --git a/server/src/services/tiktok/payload.rs b/server/src/services/tiktok/payload.rs new file mode 100644 index 0000000..39a3ce4 --- /dev/null +++ b/server/src/services/tiktok/payload.rs @@ -0,0 +1,536 @@ +//! Logique pure du service TikTok : découpage en chunks, choix de la +//! confidentialité, corps de l'initialisation et lecture des réponses de l'API. + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use crate::extract::is_truthy; + +/// TikTok impose des chunks entre 5 Mo et 64 Mo. +pub const MAX_SINGLE_CHUNK_SIZE: i64 = 64 * 1024 * 1024; +pub const CHUNK_SIZE: i64 = 32 * 1024 * 1024; + +/// Longueur maximale de la légende d'une vidéo TikTok. +pub const MAX_TITLE_LENGTH: usize = 2200; + +/// Options réellement autorisées sur un compte, renvoyées telles quelles au client. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatorInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_nickname: Option, + pub privacy_level_options: Vec, + pub comment_disabled: bool, + pub duet_disabled: bool, + pub stitch_disabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_video_post_duration_sec: Option, +} + +impl CreatorInfo { + /// Lecture du champ `data` de `creator_info/query`. + pub fn from_data(data: &Value) -> Self { + // Un champ absent reste absent (comme `undefined` côté JavaScript) + let field = |key: &str| data.get(key).cloned(); + let privacy_level_options = data + .get("privacy_level_options") + .and_then(Value::as_array) + .map(|options| { + options + .iter() + .filter_map(|o| o.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Self { + creator_avatar_url: field("creator_avatar_url"), + creator_username: field("creator_username"), + creator_nickname: field("creator_nickname"), + privacy_level_options, + comment_disabled: data.truthy_field("comment_disabled"), + duet_disabled: data.truthy_field("duet_disabled"), + stitch_disabled: data.truthy_field("stitch_disabled"), + max_video_post_duration_sec: field("max_video_post_duration_sec"), + } + } +} + +/// Préférences de publication (toutes facultatives). +#[derive(Debug, Clone, Default)] +pub struct PublishOptions { + pub privacy_level: Option, + pub disable_comment: bool, + pub disable_duet: bool, + pub disable_stitch: bool, +} + +/// État d'une publication renvoyé par `/post/publish/status/fetch/`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PublishStatus { + pub status: Option, + pub fail_reason: Option, + pub post_id: Option, +} + +impl PublishStatus { + pub fn from_data(data: &Value) -> Self { + // Le nom du champ comporte bien une faute de frappe côté API TikTok + let ids = [ + data.get("publicaly_available_post_id"), + data.get("publicly_available_post_id"), + ] + .into_iter() + .flatten() + .find(|v| is_truthy(Some(v))); + let post_id = ids + .and_then(Value::as_array) + .and_then(|ids| ids.first()) + .and_then(js_string); + Self { + status: data.get("status").and_then(js_string), + fail_reason: data.get("fail_reason").and_then(js_string), + post_id, + } + } +} + +/// Jetons renvoyés par `/oauth/token/`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TokenResponse { + pub access_token: String, + pub expires_in: Option, + pub open_id: Option, + pub refresh_token: Option, + pub refresh_expires_in: Option, + pub scope: Option, +} + +impl TokenResponse { + pub fn from_value(data: &Value) -> Self { + Self { + access_token: data + .get("access_token") + .and_then(js_string) + .unwrap_or_default(), + expires_in: data.get("expires_in").and_then(Value::as_f64), + open_id: data.get("open_id").and_then(js_string), + refresh_token: data.get("refresh_token").and_then(js_string), + refresh_expires_in: data.get("refresh_expires_in").and_then(Value::as_f64), + scope: data.get("scope").and_then(js_string), + } + } +} + +trait TruthyField { + fn truthy_field(&self, key: &str) -> bool; +} + +impl TruthyField for Value { + fn truthy_field(&self, key: &str) -> bool { + is_truthy(self.get(key)) + } +} + +/// Valeur scalaire convertie en texte (`String(x)`), `None` pour null / objet. +pub fn js_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// Rendu d'une valeur dans un gabarit JavaScript (`${x}`). +pub fn js_display(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Object(_) => "[object Object]".into(), + Value::Array(items) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + other => js_display(other), + }) + .collect::>() + .join(","), + other => js_string(other).unwrap_or_default(), + } +} + +/// Première valeur « truthy » d'une chaîne de `a || b || …`, rendue en texte. +pub fn first_truthy(values: &[Option<&Value>]) -> Option { + values + .iter() + .find(|v| is_truthy(**v)) + .and_then(|v| v.map(js_display)) +} + +/// `s.slice(0, n)` : longueur comptée en unités UTF-16, comme en JavaScript +/// (sans couper un caractère en deux). +pub fn js_slice(s: &str, max: usize) -> String { + let mut used = 0; + let mut out = String::new(); + for c in s.chars() { + used += c.len_utf16(); + if used > max { + break; + } + out.push(c); + } + out +} + +/// Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo, +/// le dernier absorbant le reliquat (règle imposée par TikTok). +pub fn compute_chunking(video_size: i64) -> (i64, i64) { + if video_size <= MAX_SINGLE_CHUNK_SIZE { + return (video_size, 1); + } + (CHUNK_SIZE, video_size / CHUNK_SIZE) +} + +/// Bornes (incluses) du chunk `index`. +pub fn chunk_range(index: i64, chunk_size: i64, total: i64, video_size: i64) -> (i64, i64) { + let start = index * chunk_size; + // Le dernier chunk absorbe le reliquat + let end = if index == total - 1 { + video_size - 1 + } else { + start + chunk_size - 1 + }; + (start, end) +} + +/// Choisit un niveau de confidentialité réellement autorisé sur le compte. +/// Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt +/// que d'échouer, en le signalant dans les logs. +pub fn resolve_privacy_level( + info: &CreatorInfo, + requested: Option<&str>, + page_name: &str, +) -> anyhow::Result { + let options = &info.privacy_level_options; + + if options.is_empty() { + anyhow::bail!( + "Le compte TikTok \"{page_name}\" ne propose aucun niveau de confidentialité. \ + Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish." + ); + } + + let wanted = requested + .filter(|r| !r.is_empty()) + .unwrap_or("PUBLIC_TO_EVERYONE"); + if options.iter().any(|o| o == wanted) { + return Ok(wanted.to_string()); + } + + let fallback = if options.iter().any(|o| o == "SELF_ONLY") { + "SELF_ONLY".to_string() + } else { + options[0].clone() + }; + tracing::warn!( + "⚠️ [TikTok] \"{wanted}\" indisponible sur {page_name} (options: {}). \ + Publication en {fallback}. Tant que l'application TikTok n'est pas auditée, \ + les vidéos restent privées.", + options.join(", ") + ); + Ok(fallback) +} + +/// Corps de `/post/publish/video/init/`. +pub fn build_init_body( + caption: &str, + privacy_level: &str, + info: &CreatorInfo, + options: &PublishOptions, + video_size: i64, + chunk_size: i64, + total_chunk_count: i64, +) -> Value { + json!({ + "post_info": { + "title": js_slice(caption, MAX_TITLE_LENGTH), + "privacy_level": privacy_level, + // Une option désactivée côté compte doit obligatoirement être déclarée désactivée + "disable_comment": info.comment_disabled || options.disable_comment, + "disable_duet": info.duet_disabled || options.disable_duet, + "disable_stitch": info.stitch_disabled || options.disable_stitch, + }, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": video_size, + "chunk_size": chunk_size, + "total_chunk_count": total_chunk_count, + }, + }) +} + +/// Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès : +/// il ne suffit pas de regarder le statut HTTP. Renvoie le champ `data` (ou `{}`). +pub fn parse_api_payload(status: u16, ok: bool, raw: &str, context: &str) -> anyhow::Result { + let payload: Value = serde_json::from_str(raw).map_err(|_| { + anyhow::anyhow!( + "Réponse TikTok illisible lors de la {context} ({status}): {}", + js_slice(raw, 200) + ) + })?; + + let error_code = payload.get("error").and_then(|e| e.get("code")); + let has_error_code = is_truthy(error_code) && error_code != Some(&Value::from("ok")); + if !ok || has_error_code { + let message = first_truthy(&[payload.get("error").and_then(|e| e.get("message"))]) + .unwrap_or_else(|| js_slice(raw, 200)); + let code = first_truthy(&[error_code]).unwrap_or_else(|| status.to_string()); + anyhow::bail!("Erreur TikTok lors de la {context}: {message} (code: {code})"); + } + + Ok(match payload.get("data") { + Some(data) if is_truthy(Some(data)) => data.clone(), + _ => Value::Object(Map::new()), + }) +} + +/// Lecture de la réponse de `/oauth/token/` (erreurs signalées par `error` + +/// `error_description`). +pub fn parse_token_payload(status: u16, ok: bool, raw: &str) -> anyhow::Result { + let data: Value = serde_json::from_str(raw).map_err(|_| { + anyhow::anyhow!( + "Réponse TikTok illisible ({status}): {}", + js_slice(raw, 200) + ) + })?; + + if !ok || is_truthy(data.get("error")) { + let description = first_truthy(&[data.get("error_description"), data.get("error")]) + .unwrap_or_else(|| js_slice(raw, 200)); + anyhow::bail!("Erreur OAuth TikTok: {description}"); + } + + if !is_truthy(data.get("access_token")) { + anyhow::bail!("Réponse TikTok inattendue : aucun access token reçu"); + } + + Ok(TokenResponse::from_value(&data)) +} + +/// `decodeURIComponent` : échoue (« URI malformed ») sur une séquence `%` invalide. +pub fn decode_uri_component(value: &str) -> anyhow::Result { + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let valid = i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit(); + if !valid { + anyhow::bail!("URI malformed"); + } + i += 3; + } else { + i += 1; + } + } + percent_encoding::percent_decode_str(value) + .decode_utf8() + .map(|s| s.into_owned()) + .map_err(|_| anyhow::anyhow!("URI malformed")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(options: &[&str]) -> CreatorInfo { + CreatorInfo { + privacy_level_options: options.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + #[test] + fn chunking_follows_tiktok_rules() { + assert_eq!(compute_chunking(10), (10, 1)); + assert_eq!( + compute_chunking(MAX_SINGLE_CHUNK_SIZE), + (MAX_SINGLE_CHUNK_SIZE, 1) + ); + let size = 100 * 1024 * 1024; + assert_eq!(compute_chunking(size), (CHUNK_SIZE, 3)); + // Le dernier chunk absorbe le reliquat + assert_eq!( + chunk_range(2, CHUNK_SIZE, 3, size), + (2 * CHUNK_SIZE, size - 1) + ); + assert_eq!(chunk_range(0, CHUNK_SIZE, 3, size), (0, CHUNK_SIZE - 1)); + assert_eq!(chunk_range(0, 10, 1, 10), (0, 9)); + } + + #[test] + fn privacy_level_falls_back_to_self_only() { + let all = info(&["PUBLIC_TO_EVERYONE", "SELF_ONLY"]); + assert_eq!( + resolve_privacy_level(&all, None, "p").unwrap(), + "PUBLIC_TO_EVERYONE" + ); + assert_eq!( + resolve_privacy_level(&all, Some("SELF_ONLY"), "p").unwrap(), + "SELF_ONLY" + ); + let private = info(&["MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"]); + assert_eq!( + resolve_privacy_level(&private, None, "p").unwrap(), + "SELF_ONLY" + ); + let other = info(&["FOLLOWER_OF_CREATOR"]); + assert_eq!( + resolve_privacy_level(&other, None, "p").unwrap(), + "FOLLOWER_OF_CREATOR" + ); + let err = resolve_privacy_level(&info(&[]), None, "Magasin").unwrap_err(); + assert!(err + .to_string() + .starts_with("Le compte TikTok \"Magasin\" ne propose aucun niveau")); + } + + #[test] + fn init_body_forces_account_restrictions() { + let mut creator = info(&["SELF_ONLY"]); + creator.duet_disabled = true; + let options = PublishOptions { + disable_comment: true, + ..Default::default() + }; + let body = build_init_body("Bonjour", "SELF_ONLY", &creator, &options, 10, 10, 1); + assert_eq!( + body, + json!({ + "post_info": { + "title": "Bonjour", + "privacy_level": "SELF_ONLY", + "disable_comment": true, + "disable_duet": true, + "disable_stitch": false, + }, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": 10, + "chunk_size": 10, + "total_chunk_count": 1, + }, + }) + ); + let long = "é".repeat(3000); + let body = build_init_body(&long, "SELF_ONLY", &creator, &options, 1, 1, 1); + assert_eq!( + body["post_info"]["title"].as_str().unwrap().chars().count(), + 2200 + ); + // Un emoji compte pour deux unités UTF-16 + assert_eq!(js_slice("😀😀", 3), "😀"); + } + + #[test] + fn api_payload_errors() { + let ok = r#"{"data":{"publish_id":"p1"},"error":{"code":"ok","message":""}}"#; + assert_eq!( + parse_api_payload(200, true, ok, "x").unwrap(), + json!({"publish_id":"p1"}) + ); + let failed = + r#"{"data":{},"error":{"code":"access_token_invalid","message":"Token invalide"}}"#; + assert_eq!( + parse_api_payload(401, false, failed, "suivi de la publication") + .unwrap_err() + .to_string(), + "Erreur TikTok lors de la suivi de la publication: Token invalide (code: access_token_invalid)" + ); + assert_eq!( + parse_api_payload(500, false, "{}", "ctx") + .unwrap_err() + .to_string(), + "Erreur TikTok lors de la ctx: {} (code: 500)" + ); + assert_eq!( + parse_api_payload(502, false, "", "ctx") + .unwrap_err() + .to_string(), + "Réponse TikTok illisible lors de la ctx (502): " + ); + assert_eq!( + parse_api_payload(200, true, r#"{"error":{"code":"ok"}}"#, "x").unwrap(), + json!({}) + ); + } + + #[test] + fn token_payload() { + let raw = r#"{"access_token":"a","expires_in":86400,"open_id":"o","refresh_token":"r","refresh_expires_in":31536000,"scope":"user.info.basic","token_type":"Bearer"}"#; + let tokens = parse_token_payload(200, true, raw).unwrap(); + assert_eq!(tokens.access_token, "a"); + assert_eq!(tokens.expires_in, Some(86400.0)); + assert_eq!(tokens.open_id.as_deref(), Some("o")); + let err = r#"{"error":"invalid_grant","error_description":"Code expiré"}"#; + assert_eq!( + parse_token_payload(400, false, err) + .unwrap_err() + .to_string(), + "Erreur OAuth TikTok: Code expiré" + ); + assert_eq!( + parse_token_payload(200, true, "{}") + .unwrap_err() + .to_string(), + "Réponse TikTok inattendue : aucun access token reçu" + ); + } + + #[test] + fn publish_status_reads_misspelled_field() { + let status = PublishStatus::from_data(&json!({ + "status": "PUBLISH_COMPLETE", + "publicaly_available_post_id": [7301234567890123456u64] + })); + assert_eq!(status.status.as_deref(), Some("PUBLISH_COMPLETE")); + assert_eq!(status.post_id.as_deref(), Some("7301234567890123456")); + let status = PublishStatus::from_data(&json!({ + "status": "FAILED", "fail_reason": "spam", "publicly_available_post_id": [] + })); + assert_eq!(status.fail_reason.as_deref(), Some("spam")); + assert_eq!(status.post_id, None); + } + + #[test] + fn creator_info_serialization() { + let info = CreatorInfo::from_data(&json!({ + "creator_username": "magasin", + "privacy_level_options": ["SELF_ONLY"], + "comment_disabled": true, + })); + assert_eq!( + serde_json::to_value(&info).unwrap(), + json!({ + "creatorUsername": "magasin", + "privacyLevelOptions": ["SELF_ONLY"], + "commentDisabled": true, + "duetDisabled": false, + "stitchDisabled": false, + }) + ); + } + + #[test] + fn uri_component_decoding() { + assert_eq!(decode_uri_component("a%2Ab%20c").unwrap(), "a*b c"); + assert_eq!(decode_uri_component("plain").unwrap(), "plain"); + assert!(decode_uri_component("bad%zz").is_err()); + assert!(decode_uri_component("bad%2").is_err()); + assert!(decode_uri_component("%FF").is_err()); + } +} From 52833317e6a5c3616b664ae1de33cbfefa4ff0d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:37:32 +0000 Subject: [PATCH 07/10] Compose : service remotion-renderer pour le rendu des Reels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rendu Remotion tourne dans son propre conteneur Node ; l'application Rust le joint par REMOTION_RENDERER_URL. Ancien script de pré-bundle retiré (le service pré-bundle lui-même à la construction). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- .dockerignore | 1 + docker-compose.yml | 25 +++++++++++++++++++++++-- scripts/prebundle-remotion.mjs | 25 ------------------------- 3 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 scripts/prebundle-remotion.mjs diff --git a/.dockerignore b/.dockerignore index 14de69c..3791c3a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Dépendances node_modules +**/node_modules npm-debug.log # Fichiers de build diff --git a/docker-compose.yml b/docker-compose.yml index e1003ab..63be013 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,8 +55,8 @@ services: # Rendu des Reels vidéo : remotion (sous-titres animés, identique à # l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques) REEL_RENDERER: ${REEL_RENDERER:-remotion} - # Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs) - RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-} + # Service de rendu Remotion (conteneur Node dédié, même clé que FFmpeg) + REMOTION_RENDERER_URL: http://remotion-renderer:8100 networks: - internal - nginx_default @@ -70,6 +70,27 @@ services: - logos_uploads:/app/uploads/logos - stories_uploads:/app/uploads/stories + # Rendu Remotion des Reels (sous-titres animés, logo, effet de fin) + remotion-renderer: + build: + context: . + dockerfile: remotion-service/Dockerfile + image: socialflow-remotion-renderer:latest + pull_policy: build + container_name: socialflow-remotion + restart: unless-stopped + environment: + API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} + PORT: 8100 + # Onglets Chromium par rendu (défaut : moitié des cœurs) + RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-} + MAX_PARALLEL_RENDERS: 1 + shm_size: 1gb + networks: + - internal + expose: + - "8100" + # Service FFmpeg dédié ffmpeg-api: build: diff --git a/scripts/prebundle-remotion.mjs b/scripts/prebundle-remotion.mjs deleted file mode 100644 index cd10ecb..0000000 --- a/scripts/prebundle-remotion.mjs +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -// Pré-bundle des compositions Remotion pendant le build Docker : le cache -// Webpack est chaud, le premier rendu d'un utilisateur démarre plus vite. -// Même configuration que server/services/reels/remotionRenderer.ts. -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { bundle } from "@remotion/bundler"; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - -bundle({ - entryPoint: path.join(root, "client/src/remotion/index.ts"), - webpackOverride: (config) => ({ - ...config, - resolve: { - ...config.resolve, - alias: { ...(config.resolve?.alias ?? {}), "@shared": path.join(root, "shared") }, - }, - }), -}) - .then((location) => console.log("✅ Remotion pre-bundle OK:", location)) - .catch((error) => { - console.warn("⚠️ Remotion pre-bundle skipped:", error.message); - process.exit(0); - }); From f45e13d8b1fbdfa253e5d5468c2bb11db3d662ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:37:36 +0000 Subject: [PATCH 08/10] Serveur Rust : publication Facebook, jetons et statistiques MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage de facebook.ts, facebook_oauth.ts, graph_client.ts, token_manager.ts, analytics.ts et des routes /api/facebook et /api/analytics (mêmes chemins, réponses et messages d'erreur). - Image de story avec texte incrusté : recadrage 1080×1920, bandeau translucide, DejaVu Sans gras mesurée avec harfrust, rendu resvg, emoji Twemoji (CDN jdecked, l'ancien maxcdn n'existe plus). - Stories vidéo envoyées en binaire (comme les Reels) au lieu de l'en-tête file_url, qui exigeait que Facebook joigne le serveur ; la phase FINISH et le traitement de la vidéo sont contrôlés et les erreurs remontées à l'utilisateur. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- server/Cargo.lock | 2 + server/Cargo.toml | 2 + server/src/routes/analytics.rs | 104 ++- server/src/routes/facebook.rs | 539 ++++++++++- server/src/services/analytics.rs | 469 +++++++++- server/src/services/facebook/emoji.rs | 387 ++++++++ server/src/services/facebook/graph.rs | 159 ++++ server/src/services/facebook/mod.rs | 944 +++++++++++++++++++- server/src/services/facebook/oauth.rs | 456 ++++++++++ server/src/services/facebook/story_image.rs | 685 ++++++++++++++ server/src/services/token_manager.rs | 512 ++++++++++- 11 files changed, 4228 insertions(+), 31 deletions(-) create mode 100644 server/src/services/facebook/emoji.rs create mode 100644 server/src/services/facebook/graph.rs create mode 100644 server/src/services/facebook/oauth.rs create mode 100644 server/src/services/facebook/story_image.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index b4c7eff..3feee40 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2995,6 +2995,7 @@ dependencies = [ "bytes", "chrono", "futures", + "harfrust", "hex", "hmac", "image", @@ -3008,6 +3009,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "skrifa", "sqlx", "thiserror", "tokio", diff --git a/server/Cargo.toml b/server/Cargo.toml index 597248a..b8a02ef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -18,6 +18,7 @@ bcrypt = "0.19.3" bytes = "1.12.1" chrono = { version = "0.4.45", features = ["serde"] } futures = "0.3.34" +harfrust = "0.12" hex = "0.4.3" hmac = "0.12" image = "0.25.10" @@ -31,6 +32,7 @@ scrypt = "0.11" serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.154", features = ["preserve_order"] } sha2 = "0.10" +skrifa = "0.44" 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"] } diff --git a/server/src/routes/analytics.rs b/server/src/routes/analytics.rs index f189c4f..12094d2 100644 --- a/server/src/routes/analytics.rs +++ b/server/src/routes/analytics.rs @@ -1,9 +1,109 @@ -//! STUB — routes à porter (voir `server/routes/analytics.ts`). +//! Statistiques des posts et des pages (ex-`routes/analytics.ts`). +//! +//! Routeur monté sous `/api/analytics` ; toutes les routes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header::CONTENT_TYPE; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::services::{analytics, token_manager}; use crate::state::AppState; pub fn router() -> Router { Router::new() + .route("/posts/{post_id}", get(get_post_analytics)) + .route("/posts/{post_id}/refresh", post(refresh_post_analytics)) + .route("/pages/{page_id}/history", get(get_page_history)) + .route("/pages/{page_id}/refresh", post(refresh_page_analytics)) + .route("/tokens/check", post(check_tokens)) +} + +/// `res.json(undefined)` d'Express : 200 avec un corps vide. +fn json_or_empty(value: Option) -> Response { + match value { + Some(value) => Json(value).into_response(), + None => ([(CONTENT_TYPE, "application/json; charset=utf-8")], "").into_response(), + } +} + +async fn get_post_analytics( + State(state): State, + _user: AuthUser, + Path(post_id): Path, +) -> ApiResult> { + let analytics = analytics::get_post_analytics(&state, &post_id) + .await + .or_500("Failed to fetch post analytics")?; + Ok(Json(match analytics { + Some(row) => serde_json::to_value(row).or_500("Failed to fetch post analytics")?, + None => json!({}), + })) +} + +async fn refresh_post_analytics( + State(state): State, + _user: AuthUser, + Path(post_id): Path, +) -> ApiResult { + const MESSAGE: &str = "Failed to refresh post analytics"; + analytics::sync_post_analytics(&state, &post_id) + .await + .or_500(MESSAGE)?; + let updated = analytics::get_post_analytics(&state, &post_id) + .await + .or_500(MESSAGE)?; + let value = updated + .map(serde_json::to_value) + .transpose() + .or_500(MESSAGE)?; + Ok(json_or_empty(value)) +} + +async fn get_page_history( + State(state): State, + _user: AuthUser, + Path(page_id): Path, +) -> ApiResult> { + let history = analytics::get_page_history(&state, &page_id) + .await + .or_500("Failed to fetch page history")?; + Ok(Json( + serde_json::to_value(history).or_500("Failed to fetch page history")?, + )) +} + +async fn refresh_page_analytics( + State(state): State, + _user: AuthUser, + Path(page_id): Path, +) -> ApiResult { + let fail = |error: String| { + tracing::error!("Refresh error: {error}"); + AppError::internal(if error.is_empty() { + "Failed to refresh page analytics".to_string() + } else { + error + }) + }; + analytics::sync_page_analytics(&state, &page_id) + .await + .map_err(|e| fail(e.to_string()))?; + let page = state + .db + .get_social_page(&page_id) + .await + .map_err(|e| fail(e.to_string()))?; + // Page sans ses jetons (jamais envoyés au navigateur) + Ok(json_or_empty(page.map(|p| p.to_client()))) +} + +/// Contrôle manuel des jetons. +async fn check_tokens(State(state): State, _user: AuthUser) -> Json { + token_manager::check_and_refresh_tokens(&state).await; + Json(json!({ "success": true, "message": "Token check completed" })) } diff --git a/server/src/routes/facebook.rs b/server/src/routes/facebook.rs index f149911..ce5c9ee 100644 --- a/server/src/routes/facebook.rs +++ b/server/src/routes/facebook.rs @@ -1,9 +1,544 @@ -//! STUB — routes à porter (voir `server/routes/facebook.ts`). +//! Connexion et renouvellement des pages Facebook / Instagram (ex-`routes/facebook.ts`). +//! +//! Une page connectée ici embarque le jeton utilisateur longue durée qui a servi +//! à l'obtenir : c'est ce qui permet au gestionnaire de jetons de régénérer son +//! jeton de page tout seul, au lieu d'exiger un copier-coller depuis le Graph +//! Explorer tous les deux mois. +//! +//! Routeur monté sous `/api/facebook` ; toutes les routes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header::LOCATION; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use rand::RngCore; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::config::Config; +use crate::crypto::encrypt; +use crate::db::{NewSocialPage, Patch, Platform, TokenStatus}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, QueryMap}; +use crate::services::facebook::oauth; +use crate::services::token_manager; use crate::state::AppState; +use crate::time::Ts; + +/// Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. +const RETURN_PATH: &str = "/pages"; + +/// Clé de session de l'état OAuth (protection CSRF). +const SESSION_STATE_KEY: &str = "facebookOAuthState"; + +const ADMIN_ONLY: &str = "Accès refusé. Réservé aux administrateurs."; pub fn router() -> Router { Router::new() + .route("/config", get(get_config).put(put_config)) + .route("/connect", get(connect)) + .route("/callback", get(callback)) + .route("/pages/{id}/refresh", post(refresh_page)) + .route("/tokens/check", post(check_tokens)) +} + +pub fn build_return_url(success: bool, message: &str) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("facebook", if success { "success" } else { "error" }) + .append_pair("message", message) + .finish(); + format!("{RETURN_PATH}?{query}") +} + +/// Redirection 302, comme `res.redirect` d'Express. +fn redirect(location: &str) -> Response { + match location.parse::() { + Ok(value) => (StatusCode::FOUND, [(LOCATION, value)]).into_response(), + Err(_) => (StatusCode::FOUND, [(LOCATION, RETURN_PATH)]).into_response(), + } +} + +fn redirect_error(message: &str) -> Response { + redirect(&build_return_url(false, message)) +} + +/// État de la configuration Facebook. L'App Secret n'est jamais renvoyé. +async fn get_config( + State(state): State, + user: AuthUser, + headers: HeaderMap, +) -> ApiResult> { + let config = state + .db + .get_facebook_config() + .await + .or_500("Impossible de lire la configuration Facebook")?; + let configured = oauth::is_configured(&state).await; + + // Tout le monde a besoin de savoir si la connexion est possible ; seuls les + // admins voient les détails de l'application développeur. + if !user.is_admin() { + return Ok(Json(json!({ "configured": configured }))); + } + + let app_id = config + .as_ref() + .map(|c| c.app_id.clone()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| { + if Config::var("FACEBOOK_APP_ID").is_some() { + "(défini par variable d'environnement)".to_string() + } else { + String::new() + } + }); + let has_app_secret = config.as_ref().is_some_and(|c| !c.app_secret.is_empty()) + || Config::var("FACEBOOK_APP_SECRET").is_some(); + + Ok(Json(json!({ + "configured": configured, + "appId": app_id, + "hasAppSecret": has_app_secret, + "redirectUri": oauth::redirect_uri(&state, Some(&headers)), + "scopes": oauth::scopes().split(',').collect::>(), + }))) +} + +fn js_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Champ texte validé comme `z.string().trim().min(1, message)` ; +/// `Ok(None)` = absent et facultatif. +fn required_string( + body: &Value, + key: &str, + empty_message: &str, + optional: bool, +) -> Result, String> { + match body.get(key) { + None if optional => Ok(None), + None => Err("Required".to_string()), + Some(Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Err(empty_message.to_string()) + } else { + Ok(Some(trimmed.to_string())) + } + } + Some(other) => Err(format!("Expected string, received {}", js_type(other))), + } +} + +/// Valide le corps de `PUT /config` ; renvoie (appId, appSecret facultatif). +pub fn validate_config_body( + body: &Value, + secret_optional: bool, +) -> Result<(String, Option), String> { + if !body.is_object() { + return Err(format!("Expected object, received {}", js_type(body))); + } + let app_id = required_string(body, "appId", "L'App ID ne peut pas être vide", false); + let app_secret = required_string( + body, + "appSecret", + "L'App Secret ne peut pas être vide", + secret_optional, + ); + // Première erreur dans l'ordre du schéma + let app_id = app_id?.unwrap_or_default(); + let app_secret = app_secret?; + Ok((app_id, app_secret)) +} + +/// Enregistre les identifiants de l'application Facebook (admin). +async fn put_config( + State(state): State, + user: AuthUser, + headers: HeaderMap, + Body(body): Body, +) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden(ADMIN_ONLY)); + } + + let bad_request = |message: String| { + tracing::error!("❌ [Facebook] Erreur enregistrement configuration: {message}"); + AppError::bad_request(if message.is_empty() { + "Configuration Facebook invalide".to_string() + } else { + message + }) + }; + + let existing = state + .db + .get_facebook_config() + .await + .map_err(|e| bad_request(e.to_string()))?; + // Le secret peut être omis lors d'une mise à jour : on garde l'existant + let (app_id, app_secret) = + validate_config_body(&body, existing.is_some()).map_err(bad_request)?; + + let config = state + .db + .upsert_facebook_config(Some(&app_id), app_secret.as_deref()) + .await + .map_err(|e| bad_request(e.to_string()))?; + + Ok(Json(json!({ + "success": true, + "appId": config.app_id, + "redirectUri": oauth::redirect_uri(&state, Some(&headers)), + }))) +} + +/// Démarre la connexion des pages Facebook. À appeler par navigation +/// complète du navigateur (window.location), pas en fetch. +async fn connect(State(state): State, user: AuthUser, headers: HeaderMap) -> Response { + if !oauth::is_configured(&state).await { + return redirect_error( + "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.", + ); + } + + // Protection CSRF : l'état est vérifié au retour + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let oauth_state = hex::encode(bytes); + + let auth_url = match oauth::build_authorization_url(&state, &oauth_state, Some(&headers)).await + { + Ok(url) => url, + Err(error) => { + tracing::error!("❌ [Facebook] Erreur démarrage OAuth: {error:#}"); + return redirect_error(&error.to_string()); + } + }; + + // La session doit être écrite avant la redirection, sinon l'état est perdu + let mut session = user.session; + session.set(SESSION_STATE_KEY, Value::String(oauth_state)); + if let Err(error) = session.save(&state).await { + tracing::error!("❌ [Facebook] Impossible de sauvegarder la session OAuth: {error}"); + return redirect_error("Erreur de session, réessayez."); + } + + redirect(&auth_url) +} + +/// Résumé affiché après la connexion (« 2 pages connectées, 1 page mise à jour »). +pub fn connection_summary(created: usize, updated: usize) -> String { + let plural = |n: usize| if n > 1 { "s" } else { "" }; + let mut parts = Vec::new(); + if created > 0 { + parts.push(format!( + "{created} page{} connectée{}", + plural(created), + plural(created) + )); + } + if updated > 0 { + parts.push(format!( + "{updated} page{} mise{} à jour", + plural(updated), + plural(updated) + )); + } + parts.join(", ") +} + +/// Retour d'autorisation Facebook : échange le code contre un jeton utilisateur +/// longue durée, puis crée ou met à jour chaque page administrée. +async fn callback( + State(state): State, + user: AuthUser, + headers: HeaderMap, + query: QueryMap, +) -> Response { + if let Some(oauth_error) = query.non_empty("error") { + let description = query.non_empty("error_description").unwrap_or(oauth_error); + return redirect_error(&format!("Autorisation Facebook refusée : {description}")); + } + + let AuthUser { user, mut session } = user; + let expected_state = session.get_str(SESSION_STATE_KEY); + if session.remove(SESSION_STATE_KEY).is_some() { + if let Err(error) = session.save(&state).await { + tracing::warn!( + "⚠️ [Facebook] Impossible d'effacer l'état OAuth de la session: {error}" + ); + } + } + + let received_state = query.non_empty("state"); + let state_ok = matches!( + (received_state, expected_state.as_deref()), + (Some(received), Some(expected)) if !expected.is_empty() && received == expected + ); + if !state_ok { + return redirect_error("Requête de connexion Facebook invalide (état non reconnu)."); + } + + let Some(code) = query.non_empty("code") else { + return redirect_error("Aucun code d'autorisation reçu de Facebook."); + }; + + match connect_pages(&state, &user, code, &headers).await { + Ok(Some(summary)) => { + tracing::info!("✅ [Facebook] {summary} pour {}", user.username); + redirect(&build_return_url( + true, + &format!("{summary}. Les jetons se renouvellent désormais automatiquement."), + )) + } + Ok(None) => redirect_error( + "Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page.", + ), + Err(error) => { + tracing::error!("❌ [Facebook] Erreur callback OAuth: {error:#}"); + redirect_error(&error.to_string()) + } + } +} + +/// Crée ou met à jour les pages issues de l'autorisation ; `None` quand le +/// compte n'administre aucune page. +async fn connect_pages( + state: &AppState, + user: &crate::db::User, + code: &str, + headers: &HeaderMap, +) -> anyhow::Result> { + let user_token = oauth::exchange_code_for_user_token(state, code, Some(headers)).await?; + let targets = oauth::list_pages(state, &user_token.access_token).await?; + if targets.is_empty() { + return Ok(None); + } + + // Le jeton utilisateur est la clé du renouvellement automatique : il est + // conservé chiffré, sur chaque page issue de cette autorisation. + let encrypted_user_token = encrypt(&user_token.access_token); + let existing_pages = state.db.get_social_pages(&user.id).await?; + + let mut created = 0; + let mut updated = 0; + + for target in &targets { + let existing = existing_pages + .iter() + .find(|p| p.platform == Platform::Facebook && p.page_id == target.id); + + if let Some(existing) = existing { + state + .db + .update_social_page( + &existing.id, + Patch::new() + .set("page_name", target.name.clone()) + .set("access_token", target.access_token.clone()) + // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas. + .set("token_expires_at", None::) + .set("user_access_token", encrypted_user_token.clone()) + .set("user_token_expires_at", user_token.expires_at) + .set("followers_count", target.followers_count) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()) + .set("is_active", "true"), + ) + .await?; + updated += 1; + continue; + } + + let page = state + .db + .create_social_page(NewSocialPage { + user_id: user.id.clone(), + platform: Some(Platform::Facebook), + page_id: target.id.clone(), + page_name: target.name.clone(), + access_token: target.access_token.clone(), + token_expires_at: None, + user_access_token: Some(encrypted_user_token.clone()), + user_token_expires_at: user_token.expires_at, + followers_count: Some(target.followers_count), + is_active: Some("true".to_string()), + ..Default::default() + }) + .await?; + created += 1; + + // Les non-admins ne voient que les pages qui leur sont explicitement + // attribuées : sans cette permission, ils ne verraient pas leur propre page. + if !user.is_admin() { + if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await { + tracing::error!("⚠️ [Facebook] Impossible de créer la permission de page: {error}"); + } + } + } + + // Un compte Instagram professionnel publie avec le jeton de la page Facebook + // à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il + // resterait sur un jeton saisi à la main, condamné à expirer. + for page in existing_pages + .iter() + .filter(|p| p.platform == Platform::Instagram) + { + let Some(linked) = targets + .iter() + .find(|t| t.instagram.as_ref().is_some_and(|ig| ig.id == page.page_id)) + else { + continue; + }; + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("access_token", linked.access_token.clone()) + .set("token_expires_at", None::) + .set("user_access_token", encrypted_user_token.clone()) + .set("user_token_expires_at", user_token.expires_at) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()), + ) + .await?; + updated += 1; + } + + Ok(Some(connection_summary(created, updated))) +} + +/// Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. +async fn refresh_page( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + let bad_gateway = |message: String| { + tracing::error!("❌ [Facebook] Erreur renouvellement du jeton: {message}"); + AppError::new( + StatusCode::BAD_GATEWAY, + if message.is_empty() { + "Impossible de contrôler le jeton".to_string() + } else { + message + }, + ) + }; + + let page = state + .db + .get_social_page(&id) + .await + .map_err(|e| bad_gateway(e.to_string()))? + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + + if !user.is_admin() && page.user_id != user.id { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .map_err(|e| bad_gateway(e.to_string()))?; + if !accessible.iter().any(|p| p.id == page.id) { + return Err(AppError::forbidden("Accès refusé à cette page")); + } + } + + let result = token_manager::check_page(&state, &page.id) + .await + .map_err(|e| bad_gateway(e.to_string()))?; + Ok(Json(serde_json::to_value(result).or_500_cause()?)) +} + +/// Relance le contrôle de toutes les pages (admin). +async fn check_tokens(State(state): State, user: AuthUser) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden(ADMIN_ONLY)); + } + + let results = token_manager::check_and_refresh_tokens(&state).await; + let renewed = results.iter().filter(|r| r.renewed).count(); + let failing = results + .iter() + .filter(|r| r.status == "expired" || r.status == "error") + .count(); + Ok(Json(json!({ + "checked": results.len(), + "renewed": renewed, + "failing": failing, + "results": results, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn return_url_is_encoded_like_url_search_params() { + assert_eq!( + build_return_url(false, "Erreur de session, réessayez."), + "/pages?facebook=error&message=Erreur+de+session%2C+r%C3%A9essayez." + ); + assert!(build_return_url(true, "ok").starts_with("/pages?facebook=success&message=ok")); + } + + #[test] + fn summary_wording() { + assert_eq!(connection_summary(1, 0), "1 page connectée"); + assert_eq!(connection_summary(2, 0), "2 pages connectées"); + assert_eq!(connection_summary(0, 1), "1 page mise à jour"); + assert_eq!( + connection_summary(3, 2), + "3 pages connectées, 2 pages mises à jour" + ); + assert_eq!(connection_summary(0, 0), ""); + } + + #[test] + fn config_validation_matches_zod_messages() { + let ok = + validate_config_body(&json!({ "appId": " 123 ", "appSecret": " s " }), false).unwrap(); + assert_eq!(ok, ("123".to_string(), Some("s".to_string()))); + + assert_eq!( + validate_config_body(&json!({}), false).unwrap_err(), + "Required" + ); + assert_eq!( + validate_config_body(&json!({ "appId": " " }), true).unwrap_err(), + "L'App ID ne peut pas être vide" + ); + assert_eq!( + validate_config_body(&json!({ "appId": "1" }), false).unwrap_err(), + "Required" + ); + assert_eq!( + validate_config_body(&json!({ "appId": "1", "appSecret": "" }), true).unwrap_err(), + "L'App Secret ne peut pas être vide" + ); + assert_eq!( + validate_config_body(&json!({ "appId": 12 }), true).unwrap_err(), + "Expected string, received number" + ); + // Secret facultatif lors d'une mise à jour + assert_eq!( + validate_config_body(&json!({ "appId": "1" }), true).unwrap(), + ("1".to_string(), None) + ); + } } diff --git a/server/src/services/analytics.rs b/server/src/services/analytics.rs index af1e589..ffa056e 100644 --- a/server/src/services/analytics.rs +++ b/server/src/services/analytics.rs @@ -1,11 +1,468 @@ -//! Statistiques des posts et des pages (API Graph). -//! -//! STUB — à porter depuis `server/services/analytics.ts`. -#![allow(unused_variables)] +//! Statistiques des posts et des pages via l'API Graph (ex-`analytics.ts`). +use chrono::{Duration, NaiveTime}; +use serde_json::{json, Value}; + +use crate::db::{PageAnalyticsHistory, Patch, Platform, PostAnalytics, TokenStatus}; +use crate::services::facebook::graph; use crate::state::AppState; +use crate::time::Ts; -/// Synchronise les statistiques de toutes les pages. -pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> { +const POST_FIELDS: &str = "insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)"; + +/// Codes d'erreur Graph signalant un jeton invalide ou expiré. +const AUTH_ERROR_CODES: [i64; 3] = [190, 463, 467]; + +/// Métriques d'une publication. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PostMetrics { + pub impressions: i64, + pub reach: i64, + pub engagement: i64, + pub clicks: i64, + pub reactions: i64, + pub comments: i64, + pub shares: i64, +} + +impl PostMetrics { + fn add(&mut self, other: &PostMetrics) { + self.impressions += other.impressions; + self.reach += other.reach; + self.engagement += other.engagement; + self.clicks += other.clicks; + self.reactions += other.reactions; + self.comments += other.comments; + self.shares += other.shares; + } +} + +fn number(value: Option<&Value>) -> i64 { + value.and_then(Value::as_f64).map(|n| n as i64).unwrap_or(0) +} + +/// Extrait les métriques de la réponse Graph d'un post. +pub fn parse_post_metrics(result: &Value) -> PostMetrics { + let insights = result + .get("insights") + .and_then(|i| i.get("data")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let metric = |name: &str| { + insights + .iter() + .find(|i| i.get("name").and_then(Value::as_str) == Some(name)) + .map(|m| { + number( + m.get("values") + .and_then(|v| v.get(0)) + .and_then(|v| v.get("value")), + ) + }) + .unwrap_or(0) + }; + let summary_count = |key: &str| { + number( + result + .get(key) + .and_then(|r| r.get("summary")) + .and_then(|s| s.get("total_count")), + ) + }; + + PostMetrics { + impressions: metric("post_impressions"), + reach: metric("post_impressions_unique"), + engagement: metric("post_engaged_users"), + clicks: metric("post_clicks"), + reactions: summary_count("reactions"), + comments: summary_count("comments"), + shares: number(result.get("shares").and_then(|s| s.get("count"))), + } +} + +/// Dernière valeur (la plus récente) d'une métrique de page. +pub fn last_insight_value(result: &Value) -> i64 { + result + .get("data") + .and_then(Value::as_array) + .and_then(|data| data.first()) + .and_then(|first| first.get("values")) + .and_then(Value::as_array) + .and_then(|values| values.last()) + .map(|v| number(v.get("value"))) + .unwrap_or(0) +} + +fn to_i32(value: i64) -> i32 { + value.clamp(i32::MIN as i64, i32::MAX as i64) as i32 +} + +/// Statistiques enregistrées d'un post. +pub async fn get_post_analytics( + state: &AppState, + post_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM post_analytics WHERE post_id = $1 LIMIT 1") + .bind(post_id) + .fetch_optional(&state.db.pool) + .await +} + +/// Historique d'une page : 30 derniers relevés, du plus récent au plus ancien. +pub async fn get_page_history( + state: &AppState, + page_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT * FROM page_analytics_history WHERE page_id = $1 ORDER BY date DESC LIMIT 30", + ) + .bind(page_id) + .fetch_all(&state.db.pool) + .await +} + +/// Récupère les métriques d'un post sur chaque page où il a été publié et +/// met à jour `post_analytics` (somme des publications). +pub async fn sync_post_analytics(state: &AppState, post_id: &str) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Syncing post {post_id}"); + + let published: Vec<_> = state + .db + .get_scheduled_posts_by_post(post_id) + .await? + .into_iter() + .filter(|sp| sp.external_post_id.is_some()) + .collect(); + + if published.is_empty() { + tracing::info!("[AnalyticsService] No published external posts found for {post_id}"); + return Ok(()); + } + + let mut totals = PostMetrics::default(); + let mut raw_data = Vec::new(); + + for instance in &published { + let Some(external_id) = instance + .external_post_id + .as_deref() + .filter(|id| !id.is_empty()) + else { + continue; + }; + let Some(page) = state.db.get_social_page(&instance.page_id).await? else { + continue; + }; + // Les métriques TikTok ne sont pas exposées par la Graph API + if page.platform == Platform::Tiktok { + continue; + } + + match graph::get( + state, + external_id, + Some(&page.access_token), + &[("fields", POST_FIELDS)], + ) + .await + { + Ok(result) => { + totals.add(&parse_post_metrics(&result)); + raw_data.push(json!({ "id": external_id, "data": result })); + } + Err(error) => { + tracing::error!( + "[AnalyticsService] Failed to fetch for instance {}: {error}", + instance.id + ); + } + } + } + + let raw_data = Value::Array(raw_data); + let existing = get_post_analytics(state, post_id).await?; + if let Some(existing) = existing { + sqlx::query( + r#"UPDATE post_analytics SET fetched_at = $2, impressions = $3, reach = $4, engagement = $5, + reactions = $6, comments = $7, shares = $8, clicks = $9, raw_data = $10 WHERE id = $1"#, + ) + .bind(&existing.id) + .bind(Ts::now()) + .bind(to_i32(totals.impressions)) + .bind(to_i32(totals.reach)) + .bind(to_i32(totals.engagement)) + .bind(to_i32(totals.reactions)) + .bind(to_i32(totals.comments)) + .bind(to_i32(totals.shares)) + .bind(to_i32(totals.clicks)) + .bind(&raw_data) + .execute(&state.db.pool) + .await?; + } else { + sqlx::query( + r#"INSERT INTO post_analytics (post_id, fetched_at, impressions, reach, engagement, + reactions, comments, shares, clicks, raw_data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, + ) + .bind(post_id) + .bind(Ts::now()) + .bind(to_i32(totals.impressions)) + .bind(to_i32(totals.reach)) + .bind(to_i32(totals.engagement)) + .bind(to_i32(totals.reactions)) + .bind(to_i32(totals.comments)) + .bind(to_i32(totals.shares)) + .bind(to_i32(totals.clicks)) + .bind(&raw_data) + .execute(&state.db.pool) + .await?; + } Ok(()) } + +/// Début du jour (UTC, l'heure du serveur) et lendemain. +pub fn day_bounds(now: Ts) -> (Ts, Ts) { + let start = Ts(now.0.date().and_time(NaiveTime::MIN)); + (start, start.add(Duration::days(1))) +} + +/// Synchronise les statistiques d'une page (abonnés, portée, engagement, +/// vues). Chaque métrique est récupérée séparément pour plus de résilience. +pub async fn sync_page_analytics(state: &AppState, page_id: &str) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Syncing page {page_id}"); + + let Some(page) = state.db.get_social_page(page_id).await? else { + return Ok(()); + }; + let access_token = page.access_token.as_str(); + + // 1. Infos de base (abonnés) — indispensables : un échec signale + // généralement un jeton invalide, on s'arrête là. + let mut followers = page.followers_count.unwrap_or(0) as i64; + match graph::get( + state, + &page.page_id, + Some(access_token), + &[("fields", "fan_count")], + ) + .await + { + Ok(info) => { + if let Some(fan_count) = info.get("fan_count").and_then(Value::as_f64) { + followers = fan_count as i64; + } + } + Err(error) => { + tracing::error!("[AnalyticsService] Failed to fetch basic info for {page_id}: {error}"); + let preview: String = access_token.chars().take(10).collect(); + tracing::info!("[AnalyticsService] DEBUG: Token used: {preview}..."); + + // Erreur d'authentification : jeton marqué expiré, on s'arrête + if graph::error_code(&error).is_some_and(|code| AUTH_ERROR_CODES.contains(&code)) { + tracing::info!("[AnalyticsService] Marking token as EXPIRED for page {page_id}"); + state + .db + .update_social_page( + page_id, + Patch::new() + .set("token_status", TokenStatus::Expired) + .set("last_token_check", Ts::now()), + ) + .await?; + return Ok(()); + } + + anyhow::bail!("Failed to fetch Basic Info: {error}"); + } + } + + // 2. Chaque métrique de page, indépendamment + let page_reach = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_impressions_unique", + ) + .await; + let page_engagement = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_post_engagements", + ) + .await; + let page_views = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_views_total", + ) + .await; + + tracing::info!( + "[AnalyticsService] Metrics: followers={followers}, reach={page_reach}, engagement={page_engagement}, views={page_views}" + ); + + // 3. Mise à jour de la page — le statut du jeton se rétablit tout seul + state + .db + .update_social_page( + page_id, + Patch::new() + .set("followers_count", to_i32(followers)) + .set("token_status", TokenStatus::Valid) + .set("last_token_check", Ts::now()), + ) + .await?; + + // 4. Relevé historique : un seul par jour et par page + let (today, tomorrow) = day_bounds(Ts::now()); + let existing: Option<(String,)> = sqlx::query_as( + "SELECT id FROM page_analytics_history WHERE page_id = $1 AND date >= $2 AND date < $3 LIMIT 1", + ) + .bind(page_id) + .bind(today) + .bind(tomorrow) + .fetch_optional(&state.db.pool) + .await?; + + if let Some((entry_id,)) = existing { + sqlx::query( + r#"UPDATE page_analytics_history SET followers_count = $2, page_reach = $3, page_views = $4, + page_engagement = $5, date = $6 WHERE id = $1"#, + ) + .bind(entry_id) + .bind(to_i32(followers)) + .bind(to_i32(page_reach)) + .bind(to_i32(page_views)) + .bind(to_i32(page_engagement)) + .bind(Ts::now()) + .execute(&state.db.pool) + .await?; + } else { + sqlx::query( + r#"INSERT INTO page_analytics_history (page_id, date, followers_count, page_reach, page_views, page_engagement) + VALUES ($1, $2, $3, $4, $5, $6)"#, + ) + .bind(page_id) + .bind(Ts::now()) + .bind(to_i32(followers)) + .bind(to_i32(page_reach)) + .bind(to_i32(page_views)) + .bind(to_i32(page_engagement)) + .execute(&state.db.pool) + .await?; + } + + tracing::info!( + "[AnalyticsService] Synced page {page_id}: {followers} followers, {page_reach} reach, {page_engagement} engagement, {page_views} views" + ); + Ok(()) +} + +/// Une métrique de page (`period=day`) ; 0 si elle est indisponible. +async fn fetch_insight( + state: &AppState, + graph_page_id: &str, + page_id: &str, + access_token: &str, + metric: &str, +) -> i64 { + let endpoint = format!("{graph_page_id}/insights/{metric}"); + match graph::get(state, &endpoint, Some(access_token), &[("period", "day")]).await { + Ok(result) => last_insight_value(&result), + Err(error) => { + if graph::error_code(&error) == Some(100) { + tracing::warn!("[AnalyticsService] Metric \"{metric}\" unavailable for {page_id} (Code 100). Skipping."); + } else { + tracing::warn!( + "[AnalyticsService] Failed to fetch \"{metric}\" for {page_id}: {error}" + ); + } + 0 + } + } +} + +/// Synchronise les statistiques de toutes les pages (tâche planifiée). +/// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus. +pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Starting sync for all pages..."); + let pages: Vec<_> = state + .db + .get_all_social_pages() + .await? + .into_iter() + .filter(|p| p.platform != Platform::Tiktok) + .collect(); + + for page in &pages { + if let Err(error) = sync_page_analytics(state, &page.id).await { + tracing::error!( + "[AnalyticsService] Failed to sync page {}: {error}", + page.id + ); + } + } + + tracing::info!( + "[AnalyticsService] Sync complete for {} pages.", + pages.len() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_metrics_are_extracted() { + let result = json!({ + "insights": { "data": [ + { "name": "post_impressions", "values": [{ "value": 120 }] }, + { "name": "post_impressions_unique", "values": [{ "value": 80 }] }, + { "name": "post_engaged_users", "values": [] }, + { "name": "post_clicks", "values": [{ "value": { "link clicks": 3 } }] } + ]}, + "shares": { "count": 4 }, + "comments": { "summary": { "total_count": 5 } }, + "reactions": { "summary": { "total_count": 9 } } + }); + assert_eq!( + parse_post_metrics(&result), + PostMetrics { + impressions: 120, + reach: 80, + engagement: 0, + clicks: 0, + reactions: 9, + comments: 5, + shares: 4 + } + ); + assert_eq!(parse_post_metrics(&json!({})), PostMetrics::default()); + } + + #[test] + fn last_value_of_page_insight() { + let result = json!({ "data": [{ "values": [{ "value": 1 }, { "value": 7 }] }] }); + assert_eq!(last_insight_value(&result), 7); + assert_eq!(last_insight_value(&json!({ "data": [] })), 0); + assert_eq!( + last_insight_value(&json!({ "data": [{ "values": [] }] })), + 0 + ); + } + + #[test] + fn day_bounds_are_utc_midnight() { + let (start, end) = day_bounds(Ts::parse("2026-10-12T17:45:12Z").unwrap()); + assert_eq!(start.to_iso(), "2026-10-12T00:00:00.000Z"); + assert_eq!(end.to_iso(), "2026-10-13T00:00:00.000Z"); + } +} diff --git a/server/src/services/facebook/emoji.rs b/server/src/services/facebook/emoji.rs new file mode 100644 index 0000000..bfe62cc --- /dev/null +++ b/server/src/services/facebook/emoji.rs @@ -0,0 +1,387 @@ +//! Découpage d'un texte en segments texte / emoji (ex-`twemoji-parser`). +//! +//! La table des caractères emoji est celle reconnue par twemoji-parser 14, +//! élargie au bloc « Symbols and Pictographs Extended-A » (emoji récents). + +/// Segment d'une ligne : texte brut ou emoji (avec son URL Twemoji). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Text(String), + Emoji { text: String, url: String }, +} + +/// Images Twemoji (fork maintenu, comme côté client). +const TWEMOJI_BASE: &str = "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/svg/"; + +const ZWJ: char = '\u{200D}'; +const VS16: char = '\u{FE0F}'; +const KEYCAP: char = '\u{20E3}'; + +/// Caractères reconnus seuls comme emoji (sans sélecteur de variante). +const EMOJI_RANGES: &[(u32, u32)] = &[ + (0x203c, 0x203c), + (0x2049, 0x2049), + (0x2139, 0x2139), + (0x2194, 0x2199), + (0x21a9, 0x21aa), + (0x231a, 0x231b), + (0x2328, 0x2328), + (0x23cf, 0x23cf), + (0x23e9, 0x23f3), + (0x23f8, 0x23fa), + (0x24c2, 0x24c2), + (0x25aa, 0x25ab), + (0x25b6, 0x25b6), + (0x25c0, 0x25c0), + (0x25fb, 0x25fe), + (0x2600, 0x2604), + (0x260e, 0x260e), + (0x2611, 0x2611), + (0x2614, 0x2615), + (0x2618, 0x2618), + (0x261d, 0x261d), + (0x2620, 0x2620), + (0x2622, 0x2623), + (0x2626, 0x2626), + (0x262a, 0x262a), + (0x262e, 0x262f), + (0x2638, 0x263a), + (0x2640, 0x2640), + (0x2642, 0x2642), + (0x2648, 0x2653), + (0x2660, 0x2660), + (0x2663, 0x2663), + (0x2665, 0x2666), + (0x2668, 0x2668), + (0x267b, 0x267b), + (0x267e, 0x267f), + (0x2692, 0x2697), + (0x2699, 0x2699), + (0x269b, 0x269c), + (0x26a0, 0x26a1), + (0x26a7, 0x26a7), + (0x26aa, 0x26ab), + (0x26b0, 0x26b1), + (0x26bd, 0x26be), + (0x26c4, 0x26c5), + (0x26c8, 0x26c8), + (0x26ce, 0x26cf), + (0x26d1, 0x26d1), + (0x26d3, 0x26d4), + (0x26e9, 0x26ea), + (0x26f0, 0x26f5), + (0x26f7, 0x26fa), + (0x26fd, 0x26fd), + (0x2702, 0x2702), + (0x2705, 0x2705), + (0x2708, 0x270d), + (0x270f, 0x270f), + (0x2712, 0x2712), + (0x2714, 0x2714), + (0x2716, 0x2716), + (0x271d, 0x271d), + (0x2721, 0x2721), + (0x2728, 0x2728), + (0x2733, 0x2734), + (0x2744, 0x2744), + (0x2747, 0x2747), + (0x274c, 0x274c), + (0x274e, 0x274e), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2763, 0x2764), + (0x2795, 0x2797), + (0x27a1, 0x27a1), + (0x27b0, 0x27b0), + (0x27bf, 0x27bf), + (0x2934, 0x2935), + (0x2b05, 0x2b07), + (0x2b1b, 0x2b1c), + (0x2b50, 0x2b50), + (0x2b55, 0x2b55), + (0x3030, 0x3030), + (0x303d, 0x303d), + (0x3297, 0x3297), + (0x3299, 0x3299), + (0x1f004, 0x1f004), + (0x1f0cf, 0x1f0cf), + (0x1f170, 0x1f171), + (0x1f17e, 0x1f17f), + (0x1f18e, 0x1f18e), + (0x1f191, 0x1f19a), + (0x1f1e6, 0x1f1ff), + (0x1f201, 0x1f202), + (0x1f21a, 0x1f21a), + (0x1f22f, 0x1f22f), + (0x1f232, 0x1f23a), + (0x1f250, 0x1f251), + (0x1f300, 0x1f321), + (0x1f324, 0x1f393), + (0x1f396, 0x1f397), + (0x1f399, 0x1f39b), + (0x1f39e, 0x1f3f0), + (0x1f3f3, 0x1f3f5), + (0x1f3f7, 0x1f4fd), + (0x1f4ff, 0x1f53d), + (0x1f549, 0x1f54e), + (0x1f550, 0x1f567), + (0x1f56f, 0x1f570), + (0x1f573, 0x1f57a), + (0x1f587, 0x1f587), + (0x1f58a, 0x1f58d), + (0x1f590, 0x1f590), + (0x1f595, 0x1f596), + (0x1f5a4, 0x1f5a5), + (0x1f5a8, 0x1f5a8), + (0x1f5b1, 0x1f5b2), + (0x1f5bc, 0x1f5bc), + (0x1f5c2, 0x1f5c4), + (0x1f5d1, 0x1f5d3), + (0x1f5dc, 0x1f5de), + (0x1f5e1, 0x1f5e1), + (0x1f5e3, 0x1f5e3), + (0x1f5e8, 0x1f5e8), + (0x1f5ef, 0x1f5ef), + (0x1f5f3, 0x1f5f3), + (0x1f5fa, 0x1f64f), + (0x1f680, 0x1f6c5), + (0x1f6cb, 0x1f6d2), + (0x1f6d5, 0x1f6d7), + (0x1f6dc, 0x1f6e5), + (0x1f6e9, 0x1f6e9), + (0x1f6eb, 0x1f6ec), + (0x1f6f0, 0x1f6f0), + (0x1f6f3, 0x1f6fc), + (0x1f7e0, 0x1f7eb), + (0x1f7f0, 0x1f7f0), + (0x1f90c, 0x1f93a), + (0x1f93c, 0x1f945), + (0x1f947, 0x1f9ff), + (0x1fa70, 0x1faff), +]; + +/// Caractères qui ne sont des emoji que suivis de U+FE0F (©, ®, ™, ♟). +const VS16_ONLY: &[u32] = &[0xa9, 0xae, 0x2122, 0x265f]; + +fn in_table(c: char) -> bool { + let cp = c as u32; + EMOJI_RANGES + .binary_search_by(|&(start, end)| { + if cp < start { + std::cmp::Ordering::Greater + } else if cp > end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +fn is_regional_indicator(c: char) -> bool { + ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) +} + +fn is_skin_tone(c: char) -> bool { + ('\u{1F3FB}'..='\u{1F3FF}').contains(&c) +} + +fn is_tag(c: char) -> bool { + ('\u{E0020}'..='\u{E007E}').contains(&c) +} + +fn is_keycap_base(c: char) -> bool { + c.is_ascii_digit() || c == '#' || c == '*' +} + +/// Longueur (en caractères) d'un élément emoji commençant à `i`, sans les +/// jonctions ZWJ : base, sélecteur, teinte de peau, drapeau, séquence de tags. +fn element_len(chars: &[char], i: usize) -> usize { + let c = chars[i]; + let next = |k: usize| chars.get(i + k).copied(); + + // Keycaps : 1⃣, 1️⃣, #️⃣… + if is_keycap_base(c) { + return match (next(1), next(2)) { + (Some(KEYCAP), _) => 2, + (Some(VS16), Some(KEYCAP)) => 3, + _ => 0, + }; + } + if VS16_ONLY.contains(&(c as u32)) { + return if next(1) == Some(VS16) { 2 } else { 0 }; + } + if !in_table(c) { + return 0; + } + // Drapeaux : deux indicateurs régionaux + if is_regional_indicator(c) { + return if next(1).is_some_and(is_regional_indicator) { + 2 + } else { + 1 + }; + } + + let mut len = 1; + if next(len) == Some(VS16) { + len += 1; + } + if next(len).is_some_and(is_skin_tone) { + len += 1; + } + // Drapeaux de subdivisions (🏴 + tags + U+E007F) + if c == '\u{1F3F4}' && next(len).is_some_and(is_tag) { + let mut k = len; + while next(k).is_some_and(is_tag) { + k += 1; + } + if next(k) == Some('\u{E007F}') { + len = k + 1; + } + } + len +} + +/// Longueur d'une séquence emoji complète (éléments reliés par ZWJ). +fn emoji_len(chars: &[char], start: usize) -> usize { + let mut len = element_len(chars, start); + if len == 0 { + return 0; + } + while chars.get(start + len) == Some(&ZWJ) && start + len + 1 < chars.len() { + let next = element_len(chars, start + len + 1); + if next == 0 { + break; + } + len += 1 + next; + } + len +} + +/// Nom du fichier Twemoji : points de code en hexadécimal séparés par « - », +/// sans U+FE0F sauf dans les séquences ZWJ (comme twemoji-parser). +pub fn codepoints(emoji: &str) -> String { + let keep_vs16 = emoji.contains(ZWJ); + emoji + .chars() + .filter(|c| keep_vs16 || *c != VS16) + .map(|c| format!("{:x}", c as u32)) + .collect::>() + .join("-") +} + +pub fn twemoji_url(emoji: &str) -> String { + format!("{TWEMOJI_BASE}{}.svg", codepoints(emoji)) +} + +/// Découpe le texte en segments texte / emoji. +pub fn parse(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut segments = Vec::new(); + let mut current = String::new(); + let mut i = 0; + + while i < chars.len() { + let len = emoji_len(&chars, i); + if len > 0 { + if !current.is_empty() { + segments.push(Segment::Text(std::mem::take(&mut current))); + } + let emoji: String = chars[i..i + len].iter().collect(); + segments.push(Segment::Emoji { + url: twemoji_url(&emoji), + text: emoji, + }); + i += len; + } else { + // Un U+FE0F isolé n'a pas d'image : on l'ignore + if chars[i] != VS16 { + current.push(chars[i]); + } + i += 1; + } + } + if !current.is_empty() { + segments.push(Segment::Text(current)); + } + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + fn emojis(text: &str) -> Vec { + parse(text) + .into_iter() + .filter_map(|s| match s { + Segment::Emoji { url, .. } => { + Some(url.trim_start_matches(TWEMOJI_BASE).to_string()) + } + Segment::Text(_) => None, + }) + .collect() + } + + #[test] + fn plain_text_is_one_segment() { + assert_eq!( + parse("Bonjour à tous"), + vec![Segment::Text("Bonjour à tous".into())] + ); + assert!(parse("").is_empty()); + // © ® ™ sans sélecteur, chiffres seuls : pas des emoji + assert!(emojis("© ® ™ 123 #1 *").is_empty()); + } + + #[test] + fn segments_keep_surrounding_text() { + assert_eq!( + parse("Promo 🔥 ici"), + vec![ + Segment::Text("Promo ".into()), + Segment::Emoji { + text: "🔥".into(), + url: format!("{TWEMOJI_BASE}1f525.svg") + }, + Segment::Text(" ici".into()), + ] + ); + } + + // Valeurs attendues relevées avec twemoji-parser 14 + #[test] + fn matches_twemoji_parser() { + assert_eq!( + emojis("©️ ❤ ❤️ ☀"), + vec!["a9.svg", "2764.svg", "2764.svg", "2600.svg"] + ); + assert_eq!( + emojis("1️⃣ 1⃣ #️⃣ *️⃣"), + vec!["31-20e3.svg", "31-20e3.svg", "23-20e3.svg", "2a-20e3.svg"] + ); + assert_eq!(emojis("🇫🇷 🇫"), vec!["1f1eb-1f1f7.svg", "1f1eb.svg"]); + assert_eq!(emojis("👍🏽"), vec!["1f44d-1f3fd.svg"]); + assert_eq!(emojis("👨‍👩‍👧"), vec!["1f468-200d-1f469-200d-1f467.svg"]); + assert_eq!(emojis("🏳️‍🌈"), vec!["1f3f3-fe0f-200d-1f308.svg"]); + assert_eq!(emojis("❤️‍🔥"), vec!["2764-fe0f-200d-1f525.svg"]); + assert_eq!( + emojis("👩🏻‍❤️‍👨🏼"), + vec!["1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc.svg"] + ); + assert_eq!( + emojis("🏴󠁧󠁢󠁳󠁣󠁴󠁿"), + vec!["1f3f4-e0067-e0062-e0073-e0063-e0074-e007f.svg"] + ); + assert_eq!( + emojis("😀🎉✨⭐✅"), + vec!["1f600.svg", "1f389.svg", "2728.svg", "2b50.svg", "2705.svg"] + ); + } + + #[test] + fn stray_variation_selector_is_dropped() { + assert_eq!(parse("a\u{FE0F}b"), vec![Segment::Text("ab".into())]); + } +} diff --git a/server/src/services/facebook/graph.rs b/server/src/services/facebook/graph.rs new file mode 100644 index 0000000..4c8db05 --- /dev/null +++ b/server/src/services/facebook/graph.rs @@ -0,0 +1,159 @@ +//! Client minimal de l'API Graph (ex-`utils/graph_client.ts`), utilisé par le +//! contrôle des jetons et les statistiques. + +use serde_json::Value; + +use crate::state::AppState; + +const GRAPH_API_VERSION: &str = "v19.0"; + +/// Erreur renvoyée par l'API Graph (`{ error: { message, code, fbtrace_id } }`). +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +pub struct FacebookApiError { + pub message: String, + pub status: u16, + pub code: i64, + pub fb_trace_id: String, +} + +/// Code d'erreur Graph d'une erreur `anyhow`, s'il s'agit d'une [`FacebookApiError`]. +pub fn error_code(error: &anyhow::Error) -> Option { + error.downcast_ref::().map(|e| e.code) +} + +/// URL complète d'un appel : `endpoint` avec ou sans « / » initial, jeton puis +/// paramètres en query string. +pub fn build_url( + endpoint: &str, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let path = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{endpoint}") + }; + let mut url = url::Url::parse(&format!( + "https://graph.facebook.com/{GRAPH_API_VERSION}{path}" + ))?; + { + let mut query = url.query_pairs_mut(); + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + query.append_pair("access_token", token); + } + for (key, value) in params { + query.append_pair(key, value); + } + } + Ok(url) +} + +/// `GET` sur l'API Graph. +pub async fn get( + state: &AppState, + endpoint: &str, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let url = build_url(endpoint, access_token, params)?; + tracing::info!("[GraphAPI] GET {endpoint}"); + let response = state.http.get(url).send().await?; + handle_response(response).await +} + +/// `POST` d'un corps JSON sur l'API Graph. +pub async fn post( + state: &AppState, + endpoint: &str, + body: &Value, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let url = build_url(endpoint, access_token, params)?; + tracing::info!("[GraphAPI] POST {endpoint}"); + let response = state.http.post(url).json(body).send().await?; + handle_response(response).await +} + +async fn handle_response(response: reqwest::Response) -> anyhow::Result { + let status = response.status(); + let data: Value = response.json().await?; + if !status.is_success() { + return Err(api_error(status.as_u16(), &data).into()); + } + Ok(data) +} + +fn api_error(status: u16, data: &Value) -> FacebookApiError { + let error = data.get("error"); + let field = |key: &str| error.and_then(|e| e.get(key)); + FacebookApiError { + message: field("message") + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .unwrap_or("Unknown Facebook API Error") + .to_string(), + status, + code: field("code").and_then(Value::as_i64).unwrap_or(0), + fb_trace_id: field("fbtrace_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn url_with_and_without_leading_slash() { + let a = build_url("me", Some("TOKEN"), &[]).unwrap(); + assert_eq!( + a.as_str(), + "https://graph.facebook.com/v19.0/me?access_token=TOKEN" + ); + let b = build_url( + "/123/insights/page_views_total", + Some("T"), + &[("period", "day")], + ) + .unwrap(); + assert_eq!( + b.as_str(), + "https://graph.facebook.com/v19.0/123/insights/page_views_total?access_token=T&period=day" + ); + } + + #[test] + fn params_are_encoded() { + let url = build_url( + "1_2", + Some("a b"), + &[("fields", "insights.metric(post_clicks),shares")], + ) + .unwrap(); + assert_eq!( + url.query(), + Some("access_token=a+b&fields=insights.metric%28post_clicks%29%2Cshares") + ); + } + + #[test] + fn error_structure_is_parsed() { + let err = api_error( + 400, + &json!({ "error": { "message": "Bad token", "code": 190, "fbtrace_id": "X" } }), + ); + assert_eq!(err.message, "Bad token"); + assert_eq!(err.code, 190); + assert_eq!(err.fb_trace_id, "X"); + let unknown = api_error(500, &json!({})); + assert_eq!(unknown.message, "Unknown Facebook API Error"); + assert_eq!(unknown.code, 0); + let any: anyhow::Error = err.into(); + assert_eq!(error_code(&any), Some(190)); + } +} diff --git a/server/src/services/facebook/mod.rs b/server/src/services/facebook/mod.rs index a493604..a94dac4 100644 --- a/server/src/services/facebook/mod.rs +++ b/server/src/services/facebook/mod.rs @@ -1,15 +1,158 @@ -//! Publication Facebook / Instagram (API Graph). +//! Publication Facebook (API Graph), portage de `server/services/facebook.ts`. //! -//! STUB — à porter depuis `server/services/facebook.ts` (+ `imageProcessor.ts`, -//! `emojiRenderer.ts`, `facebook_oauth.ts`, `utils/graph_client.ts`). -#![allow(unused_variables)] +//! Sous-modules : client Graph générique (`graph`), connexion OAuth et +//! renouvellement des jetons (`oauth`), image de story avec texte incrusté +//! (`story_image`, `emoji`). +pub mod emoji; +pub mod graph; +pub mod oauth; +pub mod story_image; + +use std::time::Duration; + +use anyhow::bail; use bytes::Bytes; +use once_cell::sync::Lazy; +use regex::Regex; +use reqwest::multipart::{Form, Part}; +use serde_json::Value; -use crate::db::{Media, Post, PostType, SocialPage}; +use crate::db::{Media, MediaType, Platform, Post, PostType, SocialPage}; +use crate::services::media_store; use crate::state::AppState; -/// Publie un post (feed, story…) sur une page Facebook ; renvoie l'identifiant externe. +const BASE_URL: &str = "https://graph.facebook.com/v23.0"; + +/// Suivi du traitement d'une story vidéo après la phase FINISH. +const STORY_STATUS_ATTEMPTS: u32 = 12; +const STORY_STATUS_INTERVAL: Duration = Duration::from_secs(5); + +// ---------------------------------------------------------------------- +// Utilitaires +// ---------------------------------------------------------------------- + +/// URL `/{node}/{edge}` de l'API Graph avec ses paramètres en query string. +fn graph_url(node: &str, edge: &str, params: &[(&str, &str)]) -> anyhow::Result { + let mut url = url::Url::parse(&format!("{BASE_URL}/{node}/{edge}"))?; + if !params.is_empty() { + url.query_pairs_mut().extend_pairs(params); + } + Ok(url) +} + +/// Valeur rendue comme dans un gabarit JavaScript (`${valeur}`). +fn js(value: Option<&Value>) -> String { + match value { + None => "undefined".to_string(), + Some(Value::Null) => "null".to_string(), + Some(Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + } +} + +/// Texte non vide d'un champ (identifiants renvoyés en chaîne ou en nombre). +fn field(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::String(s)) if !s.is_empty() => Some(s.clone()), + Some(Value::Number(n)) => Some(n.to_string()), + _ => None, + } +} + +/// Message d'une erreur Graph : `{prefix}: {message} (code: {code})`. +fn format_api_error(prefix: &str, status: u16, body: &str) -> String { + match serde_json::from_str::(body) { + Ok(data) if data.get("error").is_some_and(Value::is_object) => { + let error = &data["error"]; + format!( + "{prefix}: {} (code: {})", + js(error.get("message")), + js(error.get("code")) + ) + } + _ => { + let excerpt: String = body.chars().take(500).collect(); + format!("{prefix}: HTTP {status} {excerpt}") + .trim_end() + .to_string() + } + } +} + +async fn api_error(prefix: &str, response: reqwest::Response) -> anyhow::Error { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + anyhow::anyhow!(format_api_error(prefix, status, &body)) +} + +/// `POST` sans corps (paramètres en query string), réponse JSON exigée. +async fn post_query(state: &AppState, url: url::Url, error_prefix: &str) -> anyhow::Result { + let response = state.http.post(url).send().await?; + if !response.status().is_success() { + return Err(api_error(error_prefix, response).await); + } + Ok(response.json().await?) +} + +/// `POST` multipart, réponse JSON exigée. +async fn post_form( + state: &AppState, + url: url::Url, + form: Form, + error_prefix: &str, +) -> anyhow::Result { + let response = state.http.post(url).multipart(form).send().await?; + if !response.status().is_success() { + return Err(api_error(error_prefix, response).await); + } + Ok(response.json().await?) +} + +/// Contenu d'un média : fichier local `/uploads/...` (à défaut, son URL +/// publique) ou URL distante. +async fn get_media_buffer(state: &AppState, url: &str) -> anyhow::Result { + if url.starts_with("http") { + let response = state.http.get(url).send().await?; + if !response.status().is_success() { + bail!("Failed to fetch media from {url}"); + } + return Ok(response.bytes().await?); + } + media_store::read_media_buffer(state, url).await +} + +fn basename(url: &str) -> String { + url.rsplit('/').next().unwrap_or(url).to_string() +} + +fn image_mime(file_name: &str) -> &'static str { + if file_name.to_lowercase().ends_with(".png") { + "image/png" + } else { + "image/jpeg" + } +} + +fn file_part(data: Bytes, file_name: String, mime: &str) -> anyhow::Result { + Ok(Part::stream(reqwest::Body::from(data)) + .file_name(file_name) + .mime_str(mime)?) +} + +fn require_token(page: &SocialPage) -> anyhow::Result<&str> { + if page.access_token.is_empty() { + bail!("No access token found for this page"); + } + Ok(&page.access_token) +} + +// ---------------------------------------------------------------------- +// Publication +// ---------------------------------------------------------------------- + +/// Publie un post (feed, story, reel) sur une page Facebook ; renvoie +/// l'identifiant externe (plusieurs stories : identifiants séparés par « , »). pub async fn publish_post( state: &AppState, post: &Post, @@ -17,24 +160,793 @@ pub async fn publish_post( post_type: PostType, media: &[Media], ) -> anyhow::Result { - anyhow::bail!("publication Facebook non implémentée") + if page.platform != Platform::Facebook { + bail!("This service only supports Facebook pages"); + } + require_token(page)?; + + match post_type { + // Les nouveaux posts « both » sont scindés à la création : seul un + // ancien enregistrement peut arriver ici. + PostType::Both => bail!( + "postType \"both\" is not supported. Posts should be split into separate feed and story entries at creation." + ), + PostType::Story => { + if media.is_empty() { + bail!("Stories require media (photo or video)"); + } + // Une story par média + let mut story_ids = Vec::with_capacity(media.len()); + for item in media { + story_ids.push(publish_story(state, post, page, item).await?); + } + Ok(story_ids.join(",")) + } + PostType::Reel => { + if media.is_empty() { + bail!("Reels require a video"); + } + let Some(video) = media.iter().find(|m| m.media_type == MediaType::Video) else { + bail!("Reels require a video media"); + }; + // Lecture locale d'abord, URL publique en secours + let buffer = get_media_buffer(state, &video.original_url).await?; + publish_video_from_buffer(state, page, buffer, &post.content).await + } + PostType::Feed => { + let images: Vec<&Media> = media.iter().filter(|m| m.media_type == MediaType::Image).collect(); + let videos: Vec<&Media> = media.iter().filter(|m| m.media_type == MediaType::Video).collect(); + + if images.len() > 1 { + publish_multi_photo_post(state, post, page, media).await + } else if let Some(image) = images.first() { + // Une image (prioritaire sur d'éventuelles vidéos) + publish_photo_post(state, post, page, image).await + } else if let Some(video) = videos.first() { + publish_video_post(state, post, page, video).await + } else { + publish_text_post(state, post, page).await + } + } + } } -/// Publie une vidéo (Reel) envoyée en binaire ; renvoie l'identifiant de la vidéo. +async fn publish_text_post( + state: &AppState, + post: &Post, + page: &SocialPage, +) -> anyhow::Result { + let url = graph_url( + &page.page_id, + "feed", + &[ + ("access_token", &page.access_token), + ("message", &post.content), + ], + )?; + let data = post_query(state, url, "Facebook API error").await?; + Ok(field(&data, "id").unwrap_or_default()) +} + +async fn publish_photo_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + let buffer = get_media_buffer(state, &media.original_url).await?; + let mut form = Form::new().text("access_token", page.access_token.clone()); + if !post.content.is_empty() { + form = form.text("message", post.content.clone()); + } + let form = form.part( + "source", + file_part( + buffer, + basename(&media.original_url), + image_mime(&media.original_url), + )?, + ); + + let url = graph_url(&page.page_id, "photos", &[])?; + let data = post_form(state, url, form, "Facebook API error").await?; + // post_id est l'identifiant complet du post + Ok(field(&data, "post_id") + .or_else(|| field(&data, "id")) + .unwrap_or_default()) +} + +/// URL Cloudinary transcodée pour Facebook (H.264, AAC, MP4) ; les autres +/// URL sont renvoyées telles quelles. +pub fn facebook_compatible_video_url(original_url: &str) -> String { + static CLOUDINARY: Lazy = Lazy::new(|| { + Regex::new(r"https://res\.cloudinary\.com/([^/]+)/video/upload/").expect("regex valide") + }); + + let Some(captures) = CLOUDINARY.captures(original_url) else { + tracing::warn!( + "Video URL is not from Cloudinary, cannot apply transformations: {original_url}" + ); + return original_url.to_string(); + }; + let full_match = captures.get(0).map(|m| m.as_str()).unwrap_or_default(); + let cloud_name = &captures[1]; + let video_path = original_url.replacen(full_match, "", 1); + + // vc_h264 : vidéo H.264 ; ac_aac : audio AAC ; f_mp4 : conteneur MP4 ; q_auto : qualité auto + let transformed = + format!("https://res.cloudinary.com/{cloud_name}/video/upload/vc_h264,ac_aac,f_mp4,q_auto/{video_path}"); + tracing::info!( + "🎬 Transforming video for Facebook compatibility: {original_url} → {transformed}" + ); + transformed +} + +async fn publish_video_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + let video_url = + facebook_compatible_video_url(&media_store::resolve_public_url(state, &media.original_url)); + let url = graph_url( + &page.page_id, + "videos", + &[ + ("access_token", &page.access_token), + ("description", &post.content), + ("file_url", &video_url), + ], + )?; + let data = post_query(state, url, "Facebook API error publishing video").await?; + Ok(field(&data, "post_id") + .or_else(|| field(&data, "id")) + .unwrap_or_default()) +} + +async fn publish_multi_photo_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &[Media], +) -> anyhow::Result { + let images: Vec<&Media> = media + .iter() + .filter(|m| m.media_type == MediaType::Image) + .collect(); + if images.is_empty() { + bail!("Multi-photo posts require at least one image"); + } + if images.len() != media.len() { + tracing::warn!( + "Filtered out {} non-image media from multi-photo post", + media.len() - images.len() + ); + } + + // 1. Envoi de chaque photo non publiée + let mut photo_ids = Vec::new(); + for image in images { + if image.original_url.is_empty() { + tracing::warn!("Skipping media {} - no valid URL found", image.id); + continue; + } + let buffer = get_media_buffer(state, &image.original_url).await?; + let form = Form::new() + .text("access_token", page.access_token.clone()) + .text("published", "false") + .part( + "source", + file_part( + buffer, + basename(&image.original_url), + image_mime(&image.original_url), + )?, + ); + let url = graph_url(&page.page_id, "photos", &[])?; + let data = post_form(state, url, form, "Facebook API error uploading photo").await?; + photo_ids.push(field(&data, "id").unwrap_or_default()); + } + + if photo_ids.is_empty() { + bail!("No photos were successfully uploaded"); + } + + // 2. Post du fil avec toutes les photos jointes + let attached: Vec<(String, String)> = photo_ids + .iter() + .enumerate() + .map(|(index, id)| { + ( + format!("attached_media[{index}]"), + serde_json::json!({ "media_fbid": id }).to_string(), + ) + }) + .collect(); + let mut params: Vec<(&str, &str)> = vec![ + ("access_token", &page.access_token), + ("message", &post.content), + ]; + params.extend(attached.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + + let url = graph_url(&page.page_id, "feed", ¶ms)?; + let data = post_query(state, url, "Facebook API error creating feed post").await?; + Ok(field(&data, "id").unwrap_or_default()) +} + +async fn publish_story( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + if media.media_type == MediaType::Video { + // L'ancien serveur passait l'URL publique de la vidéo (en-tête `file_url`) + // et Facebook devait venir la télécharger : sans APP_URL joignable depuis + // Internet (ex. http://localhost:5555), la story échouait alors que le + // Reel, envoyé en binaire, passait. On envoie donc aussi la vidéo en binaire. + let video = media_store::read_media_buffer(state, &media.original_url).await?; + return publish_video_story_from_buffer(state, page, video).await; + } + + let mut photo = get_media_buffer(state, &media.original_url).await?; + let mut file_name = basename(&media.original_url); + + if !post.content.trim().is_empty() { + let story_name = format!("story-{}.png", media.id); + match story_image::render_story_image(state, photo.clone(), &post.content).await { + Ok(with_text) => { + if let Err(error) = + media_store::upload_story_image(state, &with_text, &story_name).await + { + tracing::warn!("Impossible d'archiver l'image de story : {error:#}"); + } + photo = Bytes::from(with_text); + file_name = story_name; + tracing::info!("Story image with text generated locally"); + } + Err(error) => { + tracing::error!( + "Error generating story image with text, using original: {error:#}" + ); + } + } + } + + // 1. Photo envoyée non publiée (obligatoire pour une story) + let mime = image_mime(&file_name); + let form = Form::new() + .text("access_token", page.access_token.clone()) + .text("published", "false") + .part("source", file_part(photo, file_name, mime)?); + let url = graph_url(&page.page_id, "photos", &[])?; + let upload = post_form( + state, + url, + form, + "Facebook API error uploading photo for story", + ) + .await?; + let photo_id = field(&upload, "id").unwrap_or_default(); + + // 2. Publication de la photo en story + let url = graph_url( + &page.page_id, + "photo_stories", + &[ + ("access_token", &page.access_token), + ("photo_id", &photo_id), + ], + )?; + let story = post_query(state, url, "Facebook API error publishing story").await?; + Ok(field(&story, "post_id").unwrap_or(photo_id)) +} + +/// Démarre un envoi en plusieurs phases (`/video_stories` ou `/video_reels`) ; +/// renvoie `(video_id, upload_url)`. +fn parse_start(data: &Value) -> Option<(String, String)> { + Some((field(data, "video_id")?, field(data, "upload_url")?)) +} + +/// Envoie la vidéo en binaire vers l'`upload_url` (rupload.facebook.com). +async fn upload_binary( + state: &AppState, + upload_url: &str, + access_token: &str, + video: Bytes, +) -> Result<(), String> { + let file_size = video.len(); + let response = state + .http + .post(upload_url) + .header("Authorization", format!("OAuth {access_token}")) + .header("Content-Type", "application/octet-stream") + .header("offset", "0") + .header("file_size", file_size.to_string()) + .body(video) + .send() + .await + .map_err(|e| e.to_string())?; + let ok = response.status().is_success(); + let text = response.text().await.unwrap_or_default(); + // rupload répond `{ "success": true }` + let refused = serde_json::from_str::(&text) + .ok() + .is_some_and(|v| v.get("success") == Some(&Value::Bool(false)) || v.get("error").is_some()); + if !ok || refused { + return Err(text); + } + Ok(()) +} + +/// Publie une vidéo en story Facebook en envoyant directement les données +/// binaires (start → upload → finish sur /video_stories) ; renvoie +/// l'identifiant de la story. +pub async fn publish_video_story_from_buffer( + state: &AppState, + page: &SocialPage, + video: Bytes, +) -> anyhow::Result { + let access_token = require_token(page)?.to_string(); + + // Phase 1 : START + let url = graph_url( + &page.page_id, + "video_stories", + &[("access_token", &access_token), ("upload_phase", "start")], + )?; + let start = post_query(state, url, "Facebook Story API error (START phase)").await?; + let Some((video_id, upload_url)) = parse_start(&start) else { + bail!("Facebook Story API error (START phase): réponse inattendue {start}"); + }; + + // Phase 2 : UPLOAD + if let Err(text) = upload_binary(state, &upload_url, &access_token, video).await { + bail!("Facebook Story API error (UPLOAD phase): {text}"); + } + + // Phase 3 : FINISH + let url = graph_url( + &page.page_id, + "video_stories", + &[ + ("access_token", &access_token), + ("upload_phase", "finish"), + ("video_id", &video_id), + ], + )?; + let response = state.http.post(url).send().await?; + let status = response.status(); + let raw = response.text().await.unwrap_or_default(); + let finish: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); + if !status.is_success() || finish.get("error").is_some() { + bail!(format_api_error( + "Facebook Story API error (FINISH phase)", + status.as_u16(), + &raw + )); + } + if finish.get("success") == Some(&Value::Bool(false)) { + bail!("Facebook Story API error (FINISH phase): {raw}"); + } + + // La story n'apparaît qu'une fois la vidéo traitée : on suit son statut + // pour remonter une éventuelle erreur de traitement à l'utilisateur. + wait_for_video_story(state, &video_id, &access_token).await?; + + tracing::info!("✅ Story vidéo publiée sur {}", page.page_name); + Ok(field(&finish, "post_id").unwrap_or(video_id)) +} + +/// État du traitement d'une vidéo (`GET /{video-id}?fields=status`). +#[derive(Debug, PartialEq, Eq)] +enum VideoProcessing { + Done, + Pending, + Failed(String), +} + +fn video_processing_state(data: &Value) -> VideoProcessing { + let status = &data["status"]; + let phase_errors = |name: &str| -> Option { + let phase = &status[name]; + if phase["status"].as_str() != Some("error") { + return None; + } + let messages: Vec = phase["errors"] + .as_array() + .map(|errors| { + errors + .iter() + .filter_map(|e| e["message"].as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(if messages.is_empty() { + format!("échec de la phase {name}") + } else { + messages.join(" ; ") + }) + }; + + for phase in ["uploading_phase", "processing_phase", "publishing_phase"] { + if let Some(message) = phase_errors(phase) { + return VideoProcessing::Failed(message); + } + } + match status["video_status"].as_str() { + Some("error") => VideoProcessing::Failed("traitement de la vidéo en erreur".to_string()), + Some("ready") => VideoProcessing::Done, + _ if status["publishing_phase"]["status"].as_str() == Some("complete") => { + VideoProcessing::Done + } + _ => VideoProcessing::Pending, + } +} + +/// Attend la fin du traitement d'une story vidéo (au plus une minute). Une +/// erreur signalée par Facebook fait échouer la publication ; un statut +/// illisible ou un traitement encore en cours ne bloque pas. +async fn wait_for_video_story( + state: &AppState, + video_id: &str, + access_token: &str, +) -> anyhow::Result<()> { + for attempt in 0..STORY_STATUS_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(STORY_STATUS_INTERVAL).await; + } + let mut url = url::Url::parse(&format!("{BASE_URL}/{video_id}"))?; + url.query_pairs_mut() + .append_pair("fields", "status") + .append_pair("access_token", access_token); + let data: Value = match state.http.get(url).send().await { + Ok(response) => match response.json().await { + Ok(data) => data, + Err(error) => { + tracing::warn!("Statut de la story vidéo {video_id} illisible : {error}"); + return Ok(()); + } + }, + Err(error) => { + tracing::warn!("Statut de la story vidéo {video_id} indisponible : {error}"); + return Ok(()); + } + }; + if data.get("error").is_some() { + tracing::warn!("Statut de la story vidéo {video_id} indisponible : {data}"); + return Ok(()); + } + match video_processing_state(&data) { + VideoProcessing::Done => return Ok(()), + VideoProcessing::Failed(message) => { + bail!("Facebook Story API error (traitement de la vidéo) : {message}") + } + VideoProcessing::Pending => {} + } + } + tracing::warn!("Story vidéo {video_id} toujours en traitement chez Facebook, publication supposée en cours"); + Ok(()) +} + +/// Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → +/// finish sur /video_reels) ; renvoie l'identifiant du Reel. +/// +/// Spécifications vidéo requises pour les Reels : MP4 H.264, 9:16, 3 à 90 s, +/// 1080×1920 recommandé. 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") + let access_token = require_token(page)?.to_string(); + let file_size = video.len(); + + tracing::info!( + "🎬 Publishing video as Facebook Reel via /video_reels endpoint: page {} ({}), {:.2} MB", + page.page_id, + page.page_name, + file_size as f64 / 1024.0 / 1024.0 + ); + + // Phase 1 : START + let url = graph_url( + &page.page_id, + "video_reels", + &[("access_token", &access_token), ("upload_phase", "start")], + )?; + let response = state.http.post(url).send().await?; + let ok = response.status().is_success(); + let start_raw = response.text().await.unwrap_or_default(); + tracing::info!("📤 Facebook Reel START response: {start_raw}"); + let start: Value = serde_json::from_str(&start_raw).unwrap_or(Value::Null); + + if !ok || start.get("error").is_some() { + let error = start.get("error"); + let subcode = error.and_then(|e| e.get("error_subcode")); + let code = error.and_then(|e| e.get("code")); + if subcode.and_then(Value::as_i64) == Some(1_363_042) { + bail!( + "Le token Facebook de la page \"{}\" n'a pas la permission \"publish_video\". \ + Régénérez le token dans Graph API Explorer en ajoutant les permissions : \ + pages_manage_posts, pages_read_engagement, publish_video. (code: {}, subcode: {})", + page.page_name, + js(code), + js(subcode) + ); + } + let message = error + .and_then(|e| e.get("message")) + .filter(|m| !m.is_null()) + .map(|m| js(Some(m))) + .unwrap_or_else(|| start_raw.clone()); + bail!( + "Facebook Reel START error: {message} (code: {}, subcode: {})", + js(code), + js(subcode) + ); + } + + let Some((video_id, upload_url)) = parse_start(&start) else { + bail!("Facebook Reel START error: {start_raw} (code: undefined, subcode: undefined)"); + }; + tracing::info!("📤 Reel upload initialized: video_id={video_id}, upload_url={upload_url}"); + + // Phase 2 : UPLOAD + if let Err(text) = upload_binary(state, &upload_url, &access_token, video).await { + bail!("Facebook Reel UPLOAD error: {text}"); + } + tracing::info!("📤 Reel video data uploaded successfully ({file_size} bytes)"); + + // Phase 3 : FINISH + let mut params: Vec<(&str, &str)> = vec![ + ("access_token", &access_token), + ("upload_phase", "finish"), + ("video_id", &video_id), + ("video_state", "PUBLISHED"), + ]; + if !description.is_empty() { + params.push(("description", description)); + } + let url = graph_url(&page.page_id, "video_reels", ¶ms)?; + let response = state.http.post(url).send().await?; + let ok = response.status().is_success(); + let finish_raw = response.text().await.unwrap_or_default(); + tracing::info!("📤 Facebook Reel FINISH response: {finish_raw}"); + let finish: Value = serde_json::from_str(&finish_raw).unwrap_or(Value::Null); + + if !ok || finish.get("error").is_some() { + let error = finish.get("error"); + let message = error + .and_then(|e| e.get("message")) + .filter(|m| !m.is_null()) + .map(|m| js(Some(m))) + .unwrap_or_else(|| finish_raw.clone()); + bail!( + "Facebook Reel FINISH error: {message} (code: {})", + js(error.and_then(|e| e.get("code"))) + ); + } + + let post_id = field(&finish, "post_id").unwrap_or(video_id); + tracing::info!("✅ Reel published successfully! ID: {post_id}"); + Ok(post_id) } -/// 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") +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn graph_urls_are_built_like_url_search_params() { + let url = graph_url( + "123", + "feed", + &[("access_token", "T"), ("message", "Été & promo !")], + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://graph.facebook.com/v23.0/123/feed?access_token=T&message=%C3%89t%C3%A9+%26+promo+%21" + ); + let attached = graph_url( + "1", + "feed", + &[("attached_media[0]", r#"{"media_fbid":"9"}"#)], + ) + .unwrap(); + assert_eq!( + attached.query(), + Some("attached_media%5B0%5D=%7B%22media_fbid%22%3A%229%22%7D") + ); + } + + #[test] + fn api_errors_are_formatted_like_the_old_server() { + let body = r#"{"error":{"message":"Invalid OAuth access token.","type":"OAuthException","code":190}}"#; + assert_eq!( + format_api_error("Facebook API error", 400, body), + "Facebook API error: Invalid OAuth access token. (code: 190)" + ); + assert_eq!( + format_api_error("Facebook API error", 400, r#"{"error":{"message":"x"}}"#), + "Facebook API error: x (code: undefined)" + ); + assert_eq!( + format_api_error("Facebook API error", 502, "Bad gateway"), + "Facebook API error: HTTP 502 Bad gateway" + ); + } + + #[test] + fn cloudinary_videos_are_transcoded() { + assert_eq!( + facebook_compatible_video_url( + "https://res.cloudinary.com/demo/video/upload/v1/clip.mov" + ), + "https://res.cloudinary.com/demo/video/upload/vc_h264,ac_aac,f_mp4,q_auto/v1/clip.mov" + ); + assert_eq!( + facebook_compatible_video_url("https://app.fr/uploads/media/a.mp4"), + "https://app.fr/uploads/media/a.mp4" + ); + } + + #[test] + fn helpers() { + assert_eq!(basename("/uploads/media/123-photo.PNG"), "123-photo.PNG"); + assert_eq!(image_mime("photo.PNG"), "image/png"); + assert_eq!(image_mime("photo.jpg"), "image/jpeg"); + assert_eq!(field(&json!({ "id": 12 }), "id").as_deref(), Some("12")); + assert_eq!(field(&json!({ "post_id": "" }), "post_id"), None); + assert_eq!(js(None), "undefined"); + assert_eq!(js(Some(&json!(190))), "190"); + } + + #[test] + fn video_story_processing_states() { + let ready = json!({ "status": { "video_status": "ready" } }); + assert_eq!(video_processing_state(&ready), VideoProcessing::Done); + + let published = json!({ "status": { "video_status": "processing", + "publishing_phase": { "status": "complete" } } }); + assert_eq!(video_processing_state(&published), VideoProcessing::Done); + + let pending = json!({ "status": { "video_status": "processing", + "processing_phase": { "status": "in_progress" } } }); + assert_eq!(video_processing_state(&pending), VideoProcessing::Pending); + + let failed = json!({ "status": { "video_status": "error", + "processing_phase": { "status": "error", "errors": [{ "code": 1, "message": "Format non pris en charge" }] } } }); + assert_eq!( + video_processing_state(&failed), + VideoProcessing::Failed("Format non pris en charge".to_string()) + ); + + let bare_error = json!({ "status": { "video_status": "error" } }); + assert!(matches!( + video_processing_state(&bare_error), + VideoProcessing::Failed(_) + )); + } + + /// Appels réels à l'API Graph avec un jeton bidon : vérifie les messages + /// d'erreur remontés à l'utilisateur. + /// `cargo test graph_errors_with_fake_token -- --ignored` + #[tokio::test] + #[ignore] + async fn graph_errors_with_fake_token() { + let config = crate::config::Config { + port: 0, + database_url: String::new(), + session_secret: "x".into(), + production: false, + app_url: None, + internal_app_url: None, + static_dir: ".".into(), + public_root_dir: ".".into(), + uploads_dir: std::env::temp_dir(), + audio_upload_dir: std::env::temp_dir(), + temp_upload_dir: std::env::temp_dir(), + fonts_dir: ".".into(), + gemini_api_key: None, + external_api_key: None, + reel_renderer: "ffmpeg".into(), + remotion_url: None, + ffmpeg_api_url: None, + ffmpeg_api_key: None, + }; + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/none") + .unwrap(); + let state = AppState::new(config, pool).unwrap(); + let page = SocialPage { + id: "p".into(), + user_id: "u".into(), + platform: Platform::Facebook, + page_id: "1234567890".into(), + page_name: "Test".into(), + access_token: "fake-token".into(), + token_expires_at: None, + followers_count: None, + is_active: "true".into(), + token_status: None, + last_token_check: None, + user_access_token: None, + user_token_expires_at: None, + token_error: None, + refresh_token: None, + refresh_token_expires_at: None, + scopes: None, + avatar_url: None, + created_at: None, + }; + let post = Post { + id: "post".into(), + user_id: "u".into(), + content: "Bonjour".into(), + ai_generated: "false".into(), + product_info: None, + status: crate::db::PostStatus::Scheduled, + scheduled_for: None, + generation_status: None, + generation_progress: None, + generation_error: None, + created_at: None, + updated_at: None, + }; + + let text = publish_post(&state, &post, &page, PostType::Feed, &[]) + .await + .unwrap_err(); + println!("texte : {text}"); + assert!(text.to_string().starts_with("Facebook API error: ")); + assert!(text.to_string().contains("(code: 190)")); + + let story = publish_video_story_from_buffer(&state, &page, Bytes::from_static(b"x")) + .await + .unwrap_err(); + println!("story vidéo : {story}"); + assert!(story + .to_string() + .starts_with("Facebook Story API error (START phase): ")); + + let reel = publish_video_from_buffer(&state, &page, Bytes::from_static(b"x"), "d") + .await + .unwrap_err(); + println!("reel : {reel}"); + assert!(reel.to_string().starts_with("Facebook Reel START error: ")); + + // Story photo : image locale, texte incrusté puis envoi refusé + let photo = image::RgbImage::from_pixel(800, 600, image::Rgb([200, 80, 40])); + let name = format!("story-test-{}.jpg", std::process::id()); + photo.save(std::env::temp_dir().join(&name)).unwrap(); + let media = Media { + id: "m1".into(), + user_id: "u".into(), + media_type: MediaType::Image, + cloudinary_public_id: None, + original_url: format!("/uploads/{name}"), + facebook_landscape_url: None, + facebook_feed_url: None, + instagram_feed_url: None, + instagram_story_url: None, + thumbnail_url: None, + file_name: name.clone(), + file_size: 0, + created_at: None, + }; + let story = publish_post(&state, &post, &page, PostType::Story, &[media]) + .await + .unwrap_err(); + println!("story photo : {story}"); + assert!(story + .to_string() + .starts_with("Facebook API error uploading photo for story: ")); + let _ = std::fs::remove_file(std::env::temp_dir().join(&name)); + } } diff --git a/server/src/services/facebook/oauth.rs b/server/src/services/facebook/oauth.rs new file mode 100644 index 0000000..b628571 --- /dev/null +++ b/server/src/services/facebook/oauth.rs @@ -0,0 +1,456 @@ +//! Renouvellement des tokens Facebook (ex-`facebook_oauth.ts`). +//! +//! Le problème que ce module résout : un token de page saisi à la main finit +//! toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du +//! jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle +//! d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au +//! moment où une publication échoue. +//! +//! La parade est celle recommandée par Meta : +//! 1. l'utilisateur autorise l'application (OAuth) ; +//! 2. le code est échangé contre un token utilisateur court ; +//! 3. ce token court est échangé contre un token utilisateur longue durée +//! (~60 jours) — c'est lui que l'on conserve, chiffré ; +//! 4. les tokens de page dérivés d'un token utilisateur longue durée +//! n'expirent pas. +//! +//! Conserver le token utilisateur permet de régénérer un token de page sans +//! intervention humaine, et de prolonger le token utilisateur lui-même avant +//! son échéance : tant que l'utilisateur ne retire pas son autorisation, la +//! chaîne se renouvelle toute seule. + +use axum::http::HeaderMap; +use chrono::Duration; +use serde_json::Value; + +use crate::config::Config; +use crate::extract::is_truthy; +use crate::public_url::resolve_public_base_url; +use crate::state::AppState; +use crate::time::Ts; + +/// Permissions nécessaires pour publier et lire les statistiques d'une page. +const DEFAULT_SCOPES: &str = "pages_show_list,pages_manage_posts,pages_read_engagement,pages_manage_engagement,read_insights,business_management,instagram_basic,instagram_content_publish"; + +fn graph_version() -> String { + Config::var("FACEBOOK_GRAPH_VERSION").unwrap_or_else(|| "v19.0".to_string()) +} + +/// Erreur d'autorisation Facebook ; son message est montré tel quel à l'utilisateur. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{0}")] +pub struct FacebookAuthError(pub String); + +#[derive(Debug, Clone)] +pub struct FacebookLongLivedToken { + pub access_token: String, + /// Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). + pub expires_at: Option, +} + +#[derive(Debug, Clone)] +pub struct InstagramTarget { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct FacebookPageTarget { + pub id: String, + pub name: String, + pub access_token: String, + pub followers_count: i32, + /// Compte Instagram professionnel rattaché à la page, s'il existe. + pub instagram: Option, +} + +/// Résultat de /debug_token : ce que Meta sait vraiment du jeton. +#[derive(Debug, Clone)] +pub struct FacebookTokenInfo { + pub is_valid: bool, + /// Nul = n'expire pas (cas normal d'un token de page). + pub expires_at: Option, + pub scopes: Vec, + pub error: Option, +} + +/// Identifiants de l'application Facebook (globaux à l'instance). +/// La base prime sur les variables d'environnement. +async fn credentials(state: &AppState) -> anyhow::Result<(String, String)> { + let config = state.db.get_facebook_config().await?; + let app_id = config + .as_ref() + .map(|c| c.app_id.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| Config::var("FACEBOOK_APP_ID")); + let app_secret = config + .as_ref() + .map(|c| c.app_secret.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| Config::var("FACEBOOK_APP_SECRET")); + + match (app_id, app_secret) { + (Some(id), Some(secret)) => Ok((id, secret)), + _ => Err(FacebookAuthError( + "Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de \ + votre application Facebook dans les paramètres." + .to_string(), + ) + .into()), + } +} + +pub async fn is_configured(state: &AppState) -> bool { + credentials(state).await.is_ok() +} + +/// URI de redirection OAuth. Les en-têtes permettent de la déduire du domaine +/// réellement utilisé quand `APP_URL` n'est pas défini : sans eux on renverrait +/// `http://localhost:5555`, que Facebook rejette. +pub fn redirect_uri(state: &AppState, headers: Option<&HeaderMap>) -> String { + if let Some(uri) = Config::var("FACEBOOK_REDIRECT_URI") { + return uri; + } + format!( + "{}/api/facebook/callback", + resolve_public_base_url(state, headers) + ) +} + +pub fn scopes() -> String { + Config::var("FACEBOOK_SCOPES").unwrap_or_else(|| DEFAULT_SCOPES.to_string()) +} + +/// URL de la boîte de dialogue OAuth. +pub fn authorization_url( + version: &str, + app_id: &str, + redirect_uri: &str, + oauth_state: &str, + scopes: &str, +) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("client_id", app_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("state", oauth_state) + .append_pair("scope", scopes) + .append_pair("response_type", "code") + .finish(); + format!("https://www.facebook.com/{version}/dialog/oauth?{query}") +} + +/// URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. +pub async fn build_authorization_url( + state: &AppState, + oauth_state: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (app_id, _) = credentials(state).await?; + Ok(authorization_url( + &graph_version(), + &app_id, + &redirect_uri(state, headers), + oauth_state, + &scopes(), + )) +} + +/// Échange le code d'autorisation contre un token utilisateur **longue durée**. +/// L'étape intermédiaire (token court) n'est jamais conservée : seul le token +/// longue durée rend les tokens de page permanents. +pub async fn exchange_code_for_user_token( + state: &AppState, + code: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + let redirect = redirect_uri(state, headers); + + let short_lived = graph_get( + state, + "/oauth/access_token", + &[ + ("client_id", &app_id), + ("client_secret", &app_secret), + ("redirect_uri", &redirect), + ("code", code), + ], + "échange du code d'autorisation", + ) + .await?; + + let Some(token) = non_empty_str(&short_lived, "access_token") else { + return Err(FacebookAuthError( + "Facebook n'a pas renvoyé de jeton pour ce code d'autorisation.".to_string(), + ) + .into()); + }; + + extend_user_token(state, &token).await +} + +/// Prolonge un token utilisateur. Appelé au retour d'OAuth (token court → +/// ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée +/// encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle. +pub async fn extend_user_token( + state: &AppState, + user_token: &str, +) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + + let long_lived = graph_get( + state, + "/oauth/access_token", + &[ + ("grant_type", "fb_exchange_token"), + ("client_id", &app_id), + ("client_secret", &app_secret), + ("fb_exchange_token", user_token), + ], + "prolongation du jeton utilisateur", + ) + .await?; + + let Some(access_token) = non_empty_str(&long_lived, "access_token") else { + return Err(FacebookAuthError( + "Facebook n'a pas renvoyé de jeton longue durée.".to_string(), + ) + .into()); + }; + + // expires_in absent ou nul = jeton sans expiration connue. + let expires_at = long_lived + .get("expires_in") + .and_then(Value::as_f64) + .filter(|s| *s != 0.0 && !s.is_nan()) + .map(|seconds| Ts::now().add(Duration::milliseconds((seconds * 1000.0) as i64))); + + Ok(FacebookLongLivedToken { + access_token, + expires_at, + }) +} + +/// Pages administrées par le compte, avec leur token de page. +/// Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur +/// longue durée : c'est le cœur du renouvellement automatique. +pub async fn list_pages( + state: &AppState, + user_token: &str, +) -> anyhow::Result> { + let response = graph_get( + state, + "/me/accounts", + &[ + ( + "fields", + "id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}", + ), + ("limit", "200"), + ("access_token", user_token), + ], + "récupération des pages", + ) + .await?; + Ok(parse_pages(&response)) +} + +pub fn parse_pages(response: &Value) -> Vec { + let Some(pages) = response.get("data").and_then(Value::as_array) else { + return Vec::new(); + }; + pages + .iter() + .map(|page| { + let text = |key: &str| page.get(key).map(value_text).unwrap_or_default(); + let name = text("name"); + let count = |key: &str| page.get(key).and_then(Value::as_i64); + let followers = count("followers_count") + .or_else(|| count("fan_count")) + .unwrap_or(0); + let instagram = page + .get("instagram_business_account") + .filter(|v| v.is_object()) + .map(|ig| InstagramTarget { + id: ig.get("id").map(value_text).unwrap_or_default(), + name: non_empty_str(ig, "username") + .or_else(|| non_empty_str(ig, "name")) + .unwrap_or_else(|| name.clone()), + }); + FacebookPageTarget { + id: text("id"), + access_token: text("access_token"), + followers_count: followers as i32, + instagram, + name, + } + }) + .collect() +} + +/// Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas +/// quand le jeton expire : /debug_token le dit, ce qui permet de renouveler +/// *avant* la panne plutôt qu'après. +pub async fn inspect_token(state: &AppState, token: &str) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + // Jeton d'application : `app_id|app_secret`, accepté par /debug_token. + let app_token = format!("{app_id}|{app_secret}"); + let response = graph_get( + state, + "/debug_token", + &[("input_token", token), ("access_token", &app_token)], + "inspection du jeton", + ) + .await?; + Ok(parse_token_info(&response)) +} + +pub fn parse_token_info(response: &Value) -> FacebookTokenInfo { + let data = response.get("data").cloned().unwrap_or(Value::Null); + FacebookTokenInfo { + is_valid: data.get("is_valid") == Some(&Value::Bool(true)), + // `expires_at: 0` signifie « n'expire pas » chez Meta. + expires_at: data + .get("expires_at") + .and_then(Value::as_f64) + .filter(|s| *s != 0.0) + .and_then(|s| Ts::from_millis((s * 1000.0) as i64)), + scopes: data + .get("scopes") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + error: data + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(str::to_string), + } +} + +async fn graph_get( + state: &AppState, + endpoint: &str, + params: &[(&str, &str)], + context: &str, +) -> anyhow::Result { + let mut url = url::Url::parse(&format!( + "https://graph.facebook.com/{}{endpoint}", + graph_version() + ))?; + url.query_pairs_mut().extend_pairs(params); + + let response = state.http.get(url).send().await.map_err(|error| { + FacebookAuthError(format!( + "Facebook injoignable ({context}) : {}", + reqwest_message(&error) + )) + })?; + + let status = response.status(); + let payload: Value = response + .json() + .await + .unwrap_or_else(|_| Value::Object(Default::default())); + + if !status.is_success() || is_truthy(payload.get("error")) { + let message = payload + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + return Err(FacebookAuthError(format!("Facebook ({context}) : {message}")).into()); + } + + Ok(payload) +} + +fn reqwest_message(error: &reqwest::Error) -> String { + let text = error.to_string(); + if text.is_empty() { + "erreur réseau".to_string() + } else { + text + } +} + +/// Chaîne (ou nombre) d'un champ JSON, non vide. +fn non_empty_str(value: &Value, key: &str) -> Option { + value.get(key).map(value_text).filter(|s| !s.is_empty()) +} + +fn value_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn authorization_url_is_encoded() { + let url = authorization_url( + "v19.0", + "123", + "https://app.fr/api/facebook/callback", + "abc", + "a,b", + ); + assert_eq!( + url, + "https://www.facebook.com/v19.0/dialog/oauth?client_id=123&redirect_uri=https%3A%2F%2Fapp.fr%2Fapi%2Ffacebook%2Fcallback&state=abc&scope=a%2Cb&response_type=code" + ); + } + + #[test] + fn pages_are_mapped() { + let response = json!({ "data": [ + { "id": "1", "name": "Boutique", "access_token": "T1", "fan_count": 10, "followers_count": 12, + "instagram_business_account": { "id": "ig1", "username": "boutique.ig" } }, + { "id": "2", "name": "Autre", "access_token": "T2", "fan_count": 5, + "instagram_business_account": { "id": "ig2" } }, + { "id": "3", "name": "Sans IG", "access_token": "T3" } + ]}); + let pages = parse_pages(&response); + assert_eq!(pages.len(), 3); + assert_eq!(pages[0].followers_count, 12); + assert_eq!(pages[0].instagram.as_ref().unwrap().name, "boutique.ig"); + assert_eq!(pages[1].followers_count, 5); + assert_eq!(pages[1].instagram.as_ref().unwrap().name, "Autre"); + assert_eq!(pages[2].followers_count, 0); + assert!(pages[2].instagram.is_none()); + assert!(parse_pages(&json!({})).is_empty()); + } + + #[test] + fn token_info_is_parsed() { + let info = parse_token_info( + &json!({ "data": { "is_valid": true, "expires_at": 0, "scopes": ["a"] } }), + ); + assert!(info.is_valid); + assert!(info.expires_at.is_none()); + assert_eq!(info.scopes, vec!["a".to_string()]); + + let info = parse_token_info(&json!({ "data": { + "is_valid": false, "expires_at": 1_700_000_000, "error": { "message": "Session expired" } + }})); + assert!(!info.is_valid); + assert_eq!( + info.expires_at.unwrap().timestamp_millis(), + 1_700_000_000_000 + ); + assert_eq!(info.error.as_deref(), Some("Session expired")); + } +} diff --git a/server/src/services/facebook/story_image.rs b/server/src/services/facebook/story_image.rs new file mode 100644 index 0000000..0232c10 --- /dev/null +++ b/server/src/services/facebook/story_image.rs @@ -0,0 +1,685 @@ +//! Image de story avec le texte du post incrusté (ex-`imageProcessor.addTextToStoryImage` +//! et `emojiRenderer.ts`). +//! +//! L'image est recadrée en 1080×1920 (« cover »), un bandeau noir translucide +//! occupe le quart inférieur et le texte (sans hashtags) y est centré, en gras, +//! avec une taille de police dégressive de 72 px à 12 px pour tenir dans le +//! bandeau. Les emoji sont dessinés avec les images Twemoji. +//! +//! Rendu : composition de l'image avec `image`, bandeau avec `tiny-skia`, +//! texte et emoji via un SVG rendu par `resvg` (police DejaVu Sans). Les +//! largeurs sont mesurées avec `harfrust` (mise en forme avec crénage, comme +//! le `measureText` de node-canvas). + +use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use base64::Engine; +use bytes::Bytes; +use image::{ImageDecoder, ImageReader, Rgba, RgbaImage}; +use once_cell::sync::Lazy; +use regex::Regex; +use resvg::{tiny_skia, usvg}; + +use super::emoji::{self, Segment}; +use crate::state::AppState; + +const STORY_WIDTH: u32 = 1080; +const STORY_HEIGHT: u32 = 1920; +const TEXT_BOX_HEIGHT_RATIO: f32 = 0.25; +const MIN_FONT_SIZE: f32 = 12.0; +const MAX_FONT_SIZE: f32 = 72.0; +const PADDING: f32 = 40.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.3; +const FONT_FAMILY: &str = "DejaVu Sans"; + +/// Retire les hashtags en gardant les emoji (ex-`removeHashtags` de `shared/emoji.ts`). +pub fn remove_hashtags(text: &str) -> String { + static HASHTAG: Lazy = + Lazy::new(|| Regex::new(r"#[A-Za-z0-9_\x{00C0}-\x{017F}]+").expect("regex valide")); + static SPACES: Lazy = Lazy::new(|| Regex::new(r"\s+").expect("regex valide")); + let without = HASHTAG.replace_all(text, ""); + SPACES.replace_all(&without, " ").trim().to_string() +} + +// ---------------------------------------------------------------------- +// Découpage en lignes (logique pure, indépendante de la police) +// ---------------------------------------------------------------------- + +/// Découpe un mot trop long en morceaux tenant dans `max_width`. +fn break_long_word( + word: &str, + max_width: f32, + measure: &mut impl FnMut(&str) -> f32, +) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + for ch in word.chars() { + let mut test = current.clone(); + test.push(ch); + if measure(&test) > max_width && !current.is_empty() { + chunks.push(std::mem::replace(&mut current, ch.to_string())); + } else { + current = test; + } + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +/// Répartit le texte en lignes de largeur maximale `max_width` (mots séparés +/// par des espaces ; un mot trop long est coupé). +pub fn wrap_text(text: &str, max_width: f32, measure: &mut impl FnMut(&str) -> f32) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split(' ') { + let test = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + let width = measure(&test); + + if width > max_width && !current.is_empty() { + // La ligne déborderait : on la pousse et on repart de ce mot + lines.push(std::mem::replace(&mut current, word.to_string())); + if measure(word) > max_width { + let mut chunks = break_long_word(word, max_width, measure); + current = chunks.pop().unwrap_or_default(); + lines.extend(chunks); + } + } else if width > max_width { + // Premier mot déjà trop long + let mut chunks = break_long_word(word, max_width, measure); + current = chunks.pop().unwrap_or_default(); + lines.extend(chunks); + } else { + current = test; + } + } + + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Taille de police et lignes retenues pour tenir dans `max_height`. +/// `measure(texte, taille)` donne la largeur d'une ligne. +pub fn fit_text( + text: &str, + max_width: f32, + max_height: f32, + measure: &mut impl FnMut(&str, f32) -> f32, +) -> (f32, Vec, f32) { + let mut font_size = MAX_FONT_SIZE; + let mut lines = Vec::new(); + let mut total_height = 0.0; + + while font_size >= MIN_FONT_SIZE { + lines = wrap_text(text, max_width, &mut |t| measure(t, font_size)); + total_height = lines.len() as f32 * font_size * LINE_HEIGHT_MULTIPLIER; + if total_height <= max_height { + break; + } + font_size -= 2.0; + } + + if font_size < MIN_FONT_SIZE { + font_size = MIN_FONT_SIZE; + lines = wrap_text(text, max_width, &mut |t| measure(t, font_size)); + let line_height = font_size * LINE_HEIGHT_MULTIPLIER; + total_height = lines.len() as f32 * line_height; + + let max_lines = (max_height / line_height).floor() as usize; + if lines.len() > max_lines { + lines.truncate(max_lines); + if let Some(last) = lines.last_mut().filter(|l| !l.is_empty()) { + let keep = last.chars().count().saturating_sub(3); + *last = format!("{}...", last.chars().take(keep).collect::()); + } + total_height = max_lines as f32 * line_height; + } + } + + (font_size, lines, total_height) +} + +/// Placement « cover » de l'image : (largeur, hauteur, x, y) dans la story. +pub fn cover_rect(width: u32, height: u32) -> (f32, f32, f32, f32) { + let (sw, sh) = (STORY_WIDTH as f32, STORY_HEIGHT as f32); + let (w, h) = (width as f32, height as f32); + if w / h > sw / sh { + let draw_width = w * (sh / h); + (draw_width, sh, (sw - draw_width) / 2.0, 0.0) + } else { + let draw_height = h * (sw / w); + (sw, draw_height, 0.0, (sh - draw_height) / 2.0) + } +} + +// ---------------------------------------------------------------------- +// Police +// ---------------------------------------------------------------------- + +struct StoryFont { + data: Arc>, + fontdb: Arc, + units_per_em: f32, + ascent: f32, + descent: f32, + /// Pas de DejaVu Sans Bold disponible : gras simulé par un contour. + faux_bold: bool, +} + +const SYSTEM_FONT_DIRS: &[&str] = &[ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", +]; + +fn find_font(fonts_dir: &Path, file: &str) -> Option { + std::iter::once(fonts_dir.to_path_buf()) + .chain(SYSTEM_FONT_DIRS.iter().map(PathBuf::from)) + .map(|dir| dir.join(file)) + .find(|p| p.is_file()) +} + +impl StoryFont { + fn load(fonts_dir: &Path) -> anyhow::Result { + // Le texte est en gras : node-canvas prenait DejaVu Sans Bold sur le + // système (paquet fonts-dejavu-core), à défaut la police normale. + let (path, faux_bold) = match find_font(fonts_dir, "DejaVuSans-Bold.ttf") { + Some(path) => (path, false), + None => ( + find_font(fonts_dir, "DejaVuSans.ttf").ok_or_else(|| { + anyhow!( + "Police DejaVuSans.ttf introuvable dans {}", + fonts_dir.display() + ) + })?, + true, + ), + }; + let data = + std::fs::read(&path).with_context(|| format!("lecture de {}", path.display()))?; + + let (units_per_em, ascent, descent) = { + use skrifa::instance::{LocationRef, Size}; + use skrifa::MetadataProvider; + let font = skrifa::FontRef::new(&data).map_err(|e| anyhow!("police invalide : {e}"))?; + let metrics = font.metrics(Size::unscaled(), LocationRef::default()); + ( + metrics.units_per_em as f32, + metrics.ascent, + -metrics.descent, + ) + }; + + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(data.clone()); + tracing::info!("✓ Police {} chargée pour les stories", path.display()); + + Ok(Self { + data: Arc::new(data), + fontdb: Arc::new(db), + units_per_em, + ascent, + descent, + faux_bold, + }) + } +} + +fn story_font(fonts_dir: &Path) -> anyhow::Result<&'static StoryFont> { + static FONT: OnceLock> = OnceLock::new(); + FONT.get_or_init(|| StoryFont::load(fonts_dir).map_err(|e| format!("{e:#}"))) + .as_ref() + .map_err(|e| anyhow!("{e}")) +} + +/// Mesure des textes : largeur en unités de police, mise en cache. +struct TextMeasurer<'a> { + font: harfrust::FontRef<'a>, + shaper_data: harfrust::ShaperData, + units_per_em: f32, + cache: HashMap, +} + +impl<'a> TextMeasurer<'a> { + fn new(font: &'a StoryFont) -> anyhow::Result { + let font_ref = + harfrust::FontRef::new(&font.data).map_err(|e| anyhow!("police invalide : {e}"))?; + let shaper_data = harfrust::ShaperData::new(&font_ref); + Ok(Self { + font: font_ref, + shaper_data, + units_per_em: font.units_per_em, + cache: HashMap::new(), + }) + } + + /// Largeur d'un texte sans emoji, en pixels. + fn text_width(&mut self, text: &str, font_size: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + let units = match self.cache.get(text) { + Some(units) => *units, + None => { + let shaper = self.shaper_data.shaper(&self.font).build(); + let mut buffer = harfrust::UnicodeBuffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + let output = shaper.shape(buffer, harfrust::ShapeOptions::new()); + let units: i32 = output.glyph_positions().iter().map(|p| p.x_advance).sum(); + self.cache.insert(text.to_string(), units as f32); + units as f32 + } + }; + units * font_size / self.units_per_em + } + + /// Largeur d'une ligne : un emoji compte pour la taille de police. + fn line_width(&mut self, line: &str, font_size: f32) -> f32 { + emoji::parse(line) + .iter() + .map(|segment| match segment { + Segment::Text(text) => self.text_width(text, font_size), + Segment::Emoji { .. } => font_size, + }) + .sum() + } +} + +// ---------------------------------------------------------------------- +// Emoji (Twemoji) +// ---------------------------------------------------------------------- + +static EMOJI_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +/// Télécharge (et met en cache) l'image SVG d'un emoji. +async fn emoji_image(state: &AppState, url: &str) -> anyhow::Result { + if let Some(data) = EMOJI_CACHE.lock().ok().and_then(|c| c.get(url).cloned()) { + return Ok(data); + } + let response = state + .http + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .await?; + if !response.status().is_success() { + anyhow::bail!("Failed to fetch emoji: {}", response.status()); + } + let data = response.bytes().await?; + if let Ok(mut cache) = EMOJI_CACHE.lock() { + cache.insert(url.to_string(), data.clone()); + } + Ok(data) +} + +/// Vide le cache des emoji. +pub fn clear_emoji_cache() { + if let Ok(mut cache) = EMOJI_CACHE.lock() { + cache.clear(); + } +} + +// ---------------------------------------------------------------------- +// Rendu +// ---------------------------------------------------------------------- + +/// Télécharge l'image puis incruste le texte ; renvoie un PNG. +pub async fn add_text_to_story_image( + state: &AppState, + image_url: &str, + text: &str, +) -> anyhow::Result> { + let response = state.http.get(image_url).send().await?; + if !response.status().is_success() { + anyhow::bail!("Failed to fetch image from URL: {image_url}"); + } + let image = response.bytes().await?; + render_story_image(state, image, text).await +} + +/// Incruste le texte dans l'image fournie ; renvoie un PNG 1080×1920. +pub async fn render_story_image( + state: &AppState, + image: Bytes, + text: &str, +) -> anyhow::Result> { + let clean_text = remove_hashtags(text); + + // Les emoji sont téléchargés avant le rendu (qui est synchrone) + let mut emoji_images: HashMap = HashMap::new(); + for segment in emoji::parse(&clean_text) { + if let Segment::Emoji { url, .. } = segment { + if emoji_images.contains_key(&url) { + continue; + } + match emoji_image(state, &url).await { + Ok(data) => { + emoji_images.insert(url, data); + } + Err(error) => tracing::error!("Error downloading emoji from {url}: {error}"), + } + } + } + + let fonts_dir = state.config.fonts_dir.clone(); + tokio::task::spawn_blocking(move || { + let font = story_font(&fonts_dir)?; + compose(&image, &clean_text, font, &emoji_images) + }) + .await? +} + +fn decode_oriented(data: &[u8]) -> anyhow::Result { + let reader = ImageReader::new(Cursor::new(data)).with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + // Rotation EXIF appliquée, comme `sharp().rotate()` + let orientation = decoder.orientation()?; + let mut image = image::DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + Ok(image) +} + +fn compose( + image_data: &[u8], + text: &str, + font: &StoryFont, + emoji_images: &HashMap, +) -> anyhow::Result> { + let image = decode_oriented(image_data)?; + if image.width() == 0 || image.height() == 0 { + anyhow::bail!("image vide"); + } + + // 1. Fond noir + image en « cover » + let (draw_width, draw_height, offset_x, offset_y) = cover_rect(image.width(), image.height()); + let resized = image + .resize_exact( + draw_width.round().max(1.0) as u32, + draw_height.round().max(1.0) as u32, + image::imageops::FilterType::CatmullRom, + ) + .to_rgba8(); + let mut canvas = RgbaImage::from_pixel(STORY_WIDTH, STORY_HEIGHT, Rgba([0, 0, 0, 255])); + image::imageops::overlay( + &mut canvas, + &resized, + offset_x.round() as i64, + offset_y.round() as i64, + ); + + // Fond opaque : les pixels sont déjà « prémultipliés » + let size = tiny_skia::IntSize::from_wh(STORY_WIDTH, STORY_HEIGHT) + .ok_or_else(|| anyhow!("taille invalide"))?; + let mut pixmap = tiny_skia::Pixmap::from_vec(canvas.into_raw(), size) + .ok_or_else(|| anyhow!("pixmap invalide"))?; + + // 2. Bandeau translucide + let text_box_height = STORY_HEIGHT as f32 * TEXT_BOX_HEIGHT_RATIO; + let text_box_y = STORY_HEIGHT as f32 - text_box_height; + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(0, 0, 0, 153); + if let Some(rect) = + tiny_skia::Rect::from_xywh(0.0, text_box_y, STORY_WIDTH as f32, text_box_height) + { + pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); + } + + // 3. Texte + let max_text_width = STORY_WIDTH as f32 - PADDING * 2.0; + let max_text_height = text_box_height - PADDING * 2.0; + let mut measurer = TextMeasurer::new(font)?; + let (font_size, lines, total_height) = + fit_text(text, max_text_width, max_text_height, &mut |t, size| { + measurer.line_width(t, size) + }); + + // Emoji utilisables (SVG valide) + let valid_emoji: HashMap<&str, String> = emoji_images + .iter() + .filter(|(_, data)| usvg::Tree::from_data(data, &usvg::Options::default()).is_ok()) + .map(|(url, data)| { + let uri = format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + ); + (url.as_str(), uri) + }) + .collect(); + + let line_height = font_size * LINE_HEIGHT_MULTIPLIER; + let start_y = text_box_y + (text_box_height - total_height) / 2.0 + line_height / 2.0; + // `textBaseline = 'middle'` : milieu entre le haut et le bas de la police + let baseline_shift = (font.ascent - font.descent) / 2.0 / font.units_per_em * font_size; + + let mut svg = format!( + r#""# + ); + let text_attrs = if font.faux_bold { + format!( + r#" stroke="white" stroke-width="{:.2}" stroke-linejoin="round""#, + font_size / 24.0 + ) + } else { + String::new() + }; + + for (index, line) in lines.iter().enumerate() { + let y = start_y + index as f32 * line_height; + let line_width = measurer.line_width(line, font_size); + let mut x = (STORY_WIDTH as f32 - line_width) / 2.0; + + for segment in emoji::parse(line) { + let fallback_text = match &segment { + Segment::Text(text) => Some(text.clone()), + Segment::Emoji { text, url } => match valid_emoji.get(url.as_str()) { + Some(uri) => { + // Emoji légèrement au-dessus de la ligne de base + svg.push_str(&format!( + r#""#, + y - font_size * 0.8 + )); + x += font_size; + None + } + // Image indisponible : on écrit le caractère + None => Some(text.clone()), + }, + }; + if let Some(text) = fallback_text { + svg.push_str(&format!( + r#"{}"#, + y + baseline_shift, + xml_escape(&text) + )); + x += measurer.text_width(&text, font_size); + } + } + } + svg.push_str(""); + + let options = usvg::Options { + font_family: FONT_FAMILY.to_string(), + fontdb: font.fontdb.clone(), + ..usvg::Options::default() + }; + let tree = + usvg::Tree::from_str(&svg, &options).map_err(|e| anyhow!("SVG du texte invalide : {e}"))?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + + pixmap + .encode_png() + .map_err(|e| anyhow!("encodage PNG : {e}")) +} + +fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Caractères de contrôle interdits en XML + c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {} + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mesure factice : 10 px par caractère. + fn fixed(text: &str) -> f32 { + text.chars().count() as f32 * 10.0 + } + + #[test] + fn hashtags_are_removed() { + assert_eq!( + remove_hashtags("Promo #soldes été #Été2024 ! 🔥"), + "Promo été ! 🔥" + ); + assert_eq!(remove_hashtags(" a\n\n b\tc "), "a b c"); + assert_eq!(remove_hashtags("#seul"), ""); + } + + #[test] + fn wrap_keeps_words_together() { + let lines = wrap_text("aaa bbb ccc dd", 70.0, &mut fixed); + assert_eq!(lines, vec!["aaa bbb", "ccc dd"]); + } + + #[test] + fn wrap_breaks_long_words() { + // Premier mot trop long + assert_eq!( + wrap_text("abcdefghij", 40.0, &mut fixed), + vec!["abcd", "efgh", "ij"] + ); + // Mot trop long après une ligne existante + assert_eq!( + wrap_text("ab abcdefghij x", 40.0, &mut fixed), + vec!["ab", "abcd", "efgh", "ij x"] + ); + } + + #[test] + fn wrap_empty_text() { + assert!(wrap_text("", 100.0, &mut fixed).is_empty()); + } + + #[test] + fn font_shrinks_until_text_fits() { + // 1000 px de large, 400 px de haut ; 0,6 × taille par caractère + let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6; + let (size, lines, height) = fit_text("Bonjour", 1000.0, 400.0, &mut measure); + assert_eq!(size, 72.0); + assert_eq!(lines, vec!["Bonjour"]); + assert!((height - 93.6).abs() < 0.01); + + let long = "mot ".repeat(200); + let (size, lines, height) = fit_text(long.trim(), 1000.0, 400.0, &mut measure); + assert!((12.0..72.0).contains(&size)); + assert!(height <= 400.0); + assert!(!lines.is_empty()); + } + + #[test] + fn text_is_truncated_at_minimum_size() { + let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6; + let huge = "abcdefgh ".repeat(2000); + let (size, lines, height) = fit_text(huge.trim(), 1000.0, 400.0, &mut measure); + assert_eq!(size, 12.0); + // floor(400 / 15.6) = 25 lignes + assert_eq!(lines.len(), 25); + assert!(lines.last().unwrap().ends_with("...")); + assert!((height - 25.0 * 15.6).abs() < 0.01); + } + + #[test] + fn cover_fills_the_story() { + // Paysage : hauteur pleine, centré horizontalement + let (w, h, x, y) = cover_rect(1920, 1080); + assert_eq!(h, 1920.0); + assert!((w - 3413.33).abs() < 0.01); + assert!((x - (1080.0 - w) / 2.0).abs() < 0.01); + assert_eq!(y, 0.0); + // Carré : plus large que 9:16, hauteur pleine + let (w, h, x, y) = cover_rect(1000, 1000); + assert_eq!((w, h, x, y), (1920.0, 1920.0, -420.0, 0.0)); + // Très haut : largeur pleine, centré verticalement + let (w, h, x, y) = cover_rect(500, 2000); + assert_eq!((w, h, x, y), (1080.0, 4320.0, 0.0, -1200.0)); + } + + /// Rendu d'exemple à inspecter : `STORY_OUT=/tmp/story.png cargo test render_sample -- --ignored` + /// (télécharge les emoji depuis le CDN Twemoji). + #[tokio::test] + #[ignore] + async fn render_sample() { + let out = std::env::var("STORY_OUT").unwrap_or_else(|_| "story-sample.png".into()); + let text = std::env::var("STORY_TEXT").unwrap_or_else(|_| { + "🎉 Grande promo d'automne chez nous ! -30 % sur toute la collection 🍂🔥 \ + Venez découvrir nos nouveautés en magasin dès samedi 👉 #promo #automne #soldes" + .into() + }); + + // Image de test : dégradé paysage + let photo = RgbaImage::from_fn(1600, 900, |x, y| { + Rgba([(x / 7) as u8, (y / 4) as u8, 160, 255]) + }); + let mut jpeg = Vec::new(); + image::DynamicImage::ImageRgba8(photo) + .to_rgb8() + .write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) + .unwrap(); + + let clean = remove_hashtags(&text); + let http = reqwest::Client::new(); + let mut emoji_images = HashMap::new(); + for segment in emoji::parse(&clean) { + if let Segment::Emoji { url, .. } = segment { + match http.get(&url).send().await { + Ok(r) if r.status().is_success() => { + emoji_images.insert(url, r.bytes().await.unwrap()); + } + other => { + eprintln!("emoji {url} indisponible : {:?}", other.map(|r| r.status())) + } + } + } + } + + let fonts = Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts"); + let font = StoryFont::load(&fonts).unwrap(); + let png = compose(&jpeg, &clean, &font, &emoji_images).unwrap(); + std::fs::write(&out, png).unwrap(); + } + + #[test] + fn xml_is_escaped() { + assert_eq!(xml_escape("a, } -/// Contrôle toutes les pages et renouvelle les jetons qui doivent l'être. +impl TokenCheckResult { + fn new(page: &SocialPage, status: TokenStatus, renewed: bool, error: Option) -> Self { + Self { + page_id: page.id.clone(), + page_name: page.page_name.clone(), + status: status.as_str().to_string(), + renewed, + error, + } + } +} + +/// Contrôle toutes les pages actives et renouvelle les jetons qui doivent l'être. pub async fn check_and_refresh_tokens(state: &AppState) -> Vec { - Vec::new() + tracing::info!("[TokenManager] Starting daily token check..."); + let mut results = Vec::new(); + + let pages = match state.db.get_all_social_pages().await { + Ok(pages) => pages, + Err(error) => { + tracing::error!("[TokenManager] Critical error in checkAndRefreshTokens: {error}"); + return results; + } + }; + + for page in pages.iter().filter(|p| p.is_active == "true") { + match process_page_token(state, page).await { + Ok(result) => results.push(result), + Err(error) => { + let message = error.to_string(); + tracing::error!( + "[TokenManager] Error processing page {} ({}): {error:#}", + page.page_name, + page.id + ); + if let Err(db_error) = persist_status( + state, + &page.id, + TokenStatus::Error, + Some(message.clone()), + None, + ) + .await + { + tracing::error!( + "[TokenManager] Impossible d'enregistrer le statut de {}: {db_error}", + page.page_name + ); + } + results.push(TokenCheckResult::new( + page, + TokenStatus::Error, + false, + Some(message), + )); + } + } + } + + tracing::info!("[TokenManager] Finished token check."); + results +} + +/// Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. +pub async fn check_page(state: &AppState, page_id: &str) -> anyhow::Result { + let Some(page) = state.db.get_social_page(page_id).await? else { + anyhow::bail!("Page introuvable"); + }; + process_page_token(state, &page).await +} + +/// Échéance trop proche (ou dépassée) au regard de la fenêtre donnée. +pub fn expires_within(expires_at: Option, now: Ts, days: i64) -> bool { + expires_at.is_some_and(|at| { + at.timestamp_millis() - now.timestamp_millis() < Duration::days(days).num_milliseconds() + }) +} + +/// Date au format `toLocaleDateString('fr-FR')` (heure du serveur : UTC). +pub fn french_date(ts: Ts) -> String { + ts.0.format("%d/%m/%Y").to_string() +} + +async fn process_page_token( + state: &AppState, + page: &SocialPage, +) -> anyhow::Result { + // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure + // que 24h et se renouvelle avec le refresh token, pas via la Graph API. + if page.platform == Platform::Tiktok { + return process_tiktok_token(state, page).await; + } + + tracing::info!("[TokenManager] Checking page: {}", page.page_name); + + // Jeton déjà déchiffré par la couche d'accès aux données + let access_token = page.access_token.clone(); + + // Sans application Facebook configurée, on ne peut ni interroger + // /debug_token ni régénérer quoi que ce soit : on se rabat sur le + // contrôle historique (le jeton répond-il encore ?). + if !oauth::is_configured(state).await { + return legacy_check(state, page, &access_token).await; + } + + let info = match oauth::inspect_token(state, &access_token).await { + Ok(info) => info, + Err(error) => { + // Facebook injoignable : on ne dégrade pas le statut sur un incident + // réseau, sinon toute l'UI passerait au rouge le temps d'une panne. + tracing::warn!( + "[TokenManager] Inspection impossible pour {}: {error}", + page.page_name + ); + return legacy_check(state, page, &access_token).await; + } + }; + + let expires_at = info.expires_at; + let expiring_soon = expires_within(expires_at, Ts::now(), RENEWAL_WINDOW_DAYS); + + if info.is_valid && !expiring_soon { + // Le jeton de page tient : on en profite pour prolonger le jeton + // utilisateur s'il approche de son échéance, car c'est lui qui + // permettra le prochain renouvellement. + extend_user_token_if_needed(state, page).await; + persist_status(state, &page.id, TokenStatus::Valid, None, Some(expires_at)).await?; + return Ok(TokenCheckResult::new(page, TokenStatus::Valid, false, None)); + } + + let reason = if info.is_valid { + format!( + "Le jeton expire le {}", + expires_at + .map(french_date) + .unwrap_or_else(|| "undefined".to_string()) + ) + } else { + info.error + .clone() + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "Le jeton a été invalidé par Facebook".to_string()) + }; + tracing::warn!( + "[TokenManager] {}: {reason} — tentative de renouvellement", + page.page_name + ); + + let failing_status = if info.is_valid { + TokenStatus::Expiring + } else { + TokenStatus::Expired + }; + + // Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le + // jeton utilisateur longue durée au moment de la connexion OAuth. + match renew_from_user_token(state, page).await { + Ok(true) => { + tracing::info!( + "[TokenManager] Jeton renouvelé automatiquement pour {}", + page.page_name + ); + return Ok(TokenCheckResult::new(page, TokenStatus::Valid, true, None)); + } + Ok(false) => {} + Err(error) => { + let message = error.to_string(); + tracing::error!( + "[TokenManager] Renouvellement impossible pour {}: {message}", + page.page_name + ); + persist_status( + state, + &page.id, + failing_status, + Some(message.clone()), + Some(expires_at), + ) + .await?; + return Ok(TokenCheckResult::new( + page, + failing_status, + false, + Some(message), + )); + } + } + + // Pas de jeton utilisateur en base : la page a été ajoutée à la main, + // seule une reconnexion peut la remettre d'aplomb. + let manual = format!( + "{reason}. Reconnectez la page via « Connecter des pages Facebook » \ + pour activer le renouvellement automatique." + ); + persist_status( + state, + &page.id, + failing_status, + Some(manual.clone()), + Some(expires_at), + ) + .await?; + Ok(TokenCheckResult::new( + page, + failing_status, + false, + Some(manual), + )) +} + +/// Jeton utilisateur conservé (chiffré) sur la page, s'il existe. +fn stored_user_token(page: &SocialPage) -> Option { + page.user_access_token + .as_deref() + .filter(|t| !t.is_empty()) + .map(decrypt) +} + +/// Régénère le jeton de page à partir du jeton utilisateur longue durée. +/// Renvoie `false` quand la page n'a pas de jeton utilisateur enregistré. +async fn renew_from_user_token(state: &AppState, page: &SocialPage) -> anyhow::Result { + let Some(stored) = stored_user_token(page) else { + return Ok(false); + }; + + // On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour + // ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle. + // Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne. + let mut user_token = stored.clone(); + let mut user_token_expires_at = page.user_token_expires_at; + match oauth::extend_user_token(state, &stored).await { + Ok(extended) => { + user_token = extended.access_token; + user_token_expires_at = extended.expires_at; + } + Err(error) => tracing::warn!( + "[TokenManager] Prolongation du jeton utilisateur impossible pour {}: {error}", + page.page_name + ), + } + + let targets = oauth::list_pages(state, &user_token).await?; + // Une page Instagram est publiée avec le jeton de la page Facebook à + // laquelle son compte professionnel est rattaché. + let Some(target) = targets.iter().find(|t| { + t.id == page.page_id || t.instagram.as_ref().is_some_and(|ig| ig.id == page.page_id) + }) else { + return Err(oauth::FacebookAuthError(format!( + "La page « {} » n'est plus administrée par le compte Facebook connecté. \ + Reconnectez-la avec un compte administrateur de cette page.", + page.page_name + )) + .into()); + }; + + // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire + // pas : l'échéance reste donc généralement nulle. L'inspection est un + // confort : le jeton fraîchement émis est bon. + let page_token_expires_at = oauth::inspect_token(state, &target.access_token) + .await + .ok() + .and_then(|info| info.expires_at); + + let followers = if target.followers_count != 0 { + Some(target.followers_count) + } else { + page.followers_count + }; + + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("access_token", target.access_token.clone()) + .set("token_expires_at", page_token_expires_at) + .set("user_access_token", encrypt(&user_token)) + .set("user_token_expires_at", user_token_expires_at) + .set("followers_count", followers) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()), + ) + .await?; + + Ok(true) +} + +/// Prolonge le jeton utilisateur quand son échéance approche, même si le +/// jeton de page se porte bien : c'est ce qui évite de perdre la capacité de +/// renouveler le jour où le jeton de page est révoqué. +async fn extend_user_token_if_needed(state: &AppState, page: &SocialPage) { + let Some(stored) = stored_user_token(page) else { + return; + }; + if let Some(expires_at) = page.user_token_expires_at { + if !expires_within(Some(expires_at), Ts::now(), RENEWAL_WINDOW_DAYS) { + return; + } + } + + let result = async { + let extended = oauth::extend_user_token(state, &stored).await?; + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("user_access_token", encrypt(&extended.access_token)) + .set("user_token_expires_at", extended.expires_at), + ) + .await?; + anyhow::Ok(()) + } + .await; + + match result { + Ok(()) => tracing::info!( + "[TokenManager] Jeton utilisateur prolongé pour {}", + page.page_name + ), + Err(error) => tracing::warn!( + "[TokenManager] Impossible de prolonger le jeton utilisateur de {}: {error}", + page.page_name + ), + } +} + +/// Contrôle historique, utilisé quand l'application Facebook n'est pas +/// configurée : on sait seulement si le jeton répond encore. +async fn legacy_check( + state: &AppState, + page: &SocialPage, + access_token: &str, +) -> anyhow::Result { + if !verify_token(state, access_token).await { + let error = "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook \ + dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton." + .to_string(); + persist_status( + state, + &page.id, + TokenStatus::Expired, + Some(error.clone()), + None, + ) + .await?; + return Ok(TokenCheckResult::new( + page, + TokenStatus::Expired, + false, + Some(error), + )); + } + + // Le jeton répond : reste l'échéance déclarée à l'ajout de la page. + let status = if expires_within(page.token_expires_at, Ts::now(), WARNING_WINDOW_DAYS) { + TokenStatus::Expiring + } else { + TokenStatus::Valid + }; + persist_status(state, &page.id, status, None, None).await?; + Ok(TokenCheckResult::new(page, status, false, None)) +} + +/// Statut d'un compte TikTok d'après l'échéance de son refresh token. +pub fn tiktok_status( + refresh_token: Option<&str>, + refresh_expires_at: Option, + now: Ts, +) -> TokenStatus { + // Échéance absente = 0, comme l'ancien serveur + let expires_ms = refresh_expires_at + .map(|t| t.timestamp_millis()) + .unwrap_or(0); + let now_ms = now.timestamp_millis(); + let has_refresh = refresh_token.is_some_and(|t| !t.is_empty()); + + if !has_refresh || (expires_ms != 0 && expires_ms <= now_ms) { + TokenStatus::Expired + } else if expires_ms != 0 + && expires_ms - now_ms < Duration::days(TIKTOK_WARNING_DAYS).num_milliseconds() + { + TokenStatus::Expiring + } else { + TokenStatus::Valid + } +} + +/// Vérifie qu'un compte TikTok est toujours utilisable. +/// On ne teste pas l'access token (il est renouvelé à la demande avant chaque +/// publication) mais le refresh token, dont l'expiration déconnecterait le compte. +async fn process_tiktok_token( + state: &AppState, + page: &SocialPage, +) -> anyhow::Result { + tracing::info!("[TokenManager] Checking TikTok account: {}", page.page_name); + + let status = tiktok_status( + page.refresh_token.as_deref(), + page.refresh_token_expires_at, + Ts::now(), + ); + let error = (status == TokenStatus::Expired).then(|| { + tracing::warn!( + "[TokenManager] TikTok account {} must be reconnected", + page.page_name + ); + "L'autorisation TikTok a expiré : reconnectez le compte.".to_string() + }); + + persist_status(state, &page.id, status, error.clone(), None).await?; + Ok(TokenCheckResult::new(page, status, false, error)) +} + +/// Enregistre le résultat du contrôle ; `token_expires_at` n'est modifié que +/// s'il est fourni (`Some(None)` l'efface). +async fn persist_status( + state: &AppState, + page_id: &str, + status: TokenStatus, + error: Option, + token_expires_at: Option>, +) -> anyhow::Result<()> { + let mut patch = Patch::new() + .set("token_status", status) + .set("token_error", error) + .set("last_token_check", Ts::now()); + if let Some(expires_at) = token_expires_at { + patch = patch.set("token_expires_at", expires_at); + } + state.db.update_social_page(page_id, patch).await?; + Ok(()) +} + +/// Le jeton répond-il encore (`GET /me`) ? +async fn verify_token(state: &AppState, token: &str) -> bool { + match graph::get(state, "me", Some(token), &[]).await { + Ok(_) => true, + Err(error) => { + tracing::error!("[TokenManager] Token verification failed: {error}"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> Ts { + Ts::parse(s).unwrap() + } + + #[test] + fn expiry_window() { + let now = ts("2026-01-01T00:00:00Z"); + assert!(!expires_within(None, now, 15)); + assert!(expires_within(Some(ts("2026-01-10T00:00:00Z")), now, 15)); + assert!(expires_within(Some(ts("2025-12-01T00:00:00Z")), now, 15)); + assert!(!expires_within(Some(ts("2026-01-20T00:00:00Z")), now, 15)); + assert!(expires_within(Some(ts("2026-01-07T23:00:00Z")), now, 7)); + } + + #[test] + fn french_date_format() { + assert_eq!(french_date(ts("2026-03-05T10:00:00Z")), "05/03/2026"); + } + + #[test] + fn tiktok_statuses() { + let now = ts("2026-01-01T00:00:00Z"); + assert_eq!(tiktok_status(None, None, now), TokenStatus::Expired); + assert_eq!(tiktok_status(Some(""), None, now), TokenStatus::Expired); + assert_eq!(tiktok_status(Some("r"), None, now), TokenStatus::Valid); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2025-12-31T00:00:00Z")), now), + TokenStatus::Expired + ); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2026-01-15T00:00:00Z")), now), + TokenStatus::Expiring + ); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2026-06-01T00:00:00Z")), now), + TokenStatus::Valid + ); + } } From e221940ee450ed1dede30da0e2f16e57b3fa859c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:41:04 +0000 Subject: [PATCH 09/10] Serveur Rust : routes principales et service OpenRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage des routes définies dans server/routes.ts (hors authentification et routeurs montés à part) : utilisateurs et permissions, migration des permissions, paramètres (API externe, Gemini, Qwen et test), console SQL (résultat au format pg, plusieurs instructions), statistiques, IA (modèles, génération, historique), médias (envoi, liste paginée avec X-Total-Count, correction des URL, incrustations, suppression), pistes audio, posts, publications planifiées et agenda, pages, stockage et logo, OpenRouter et FFmpeg. Les incrustations (bandeau, prix, logo) sont rendues en SVG avec resvg et composées avec image ; l'orientation EXIF est appliquée. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- server/src/routes/core/agenda.rs | 334 ++++++++++++++ server/src/routes/core/ai.rs | 187 ++++++++ server/src/routes/core/audio.rs | 168 ++++++++ server/src/routes/core/configs.rs | 253 +++++++++++ server/src/routes/core/media.rs | 247 +++++++++++ server/src/routes/core/mod.rs | 121 +++++- server/src/routes/core/overlays.rs | 611 ++++++++++++++++++++++++++ server/src/routes/core/pages.rs | 240 +++++++++++ server/src/routes/core/posts.rs | 488 +++++++++++++++++++++ server/src/routes/core/scheduled.rs | 253 +++++++++++ server/src/routes/core/settings.rs | 207 +++++++++ server/src/routes/core/sql.rs | 645 ++++++++++++++++++++++++++++ server/src/routes/core/stats.rs | 122 ++++++ server/src/routes/core/users.rs | 254 +++++++++++ server/src/routes/core/validate.rs | 304 +++++++++++++ server/src/services/openrouter.rs | 485 ++++++++++++++++++++- 16 files changed, 4913 insertions(+), 6 deletions(-) create mode 100644 server/src/routes/core/agenda.rs create mode 100644 server/src/routes/core/ai.rs create mode 100644 server/src/routes/core/audio.rs create mode 100644 server/src/routes/core/configs.rs create mode 100644 server/src/routes/core/media.rs create mode 100644 server/src/routes/core/overlays.rs create mode 100644 server/src/routes/core/pages.rs create mode 100644 server/src/routes/core/posts.rs create mode 100644 server/src/routes/core/scheduled.rs create mode 100644 server/src/routes/core/settings.rs create mode 100644 server/src/routes/core/sql.rs create mode 100644 server/src/routes/core/stats.rs create mode 100644 server/src/routes/core/users.rs create mode 100644 server/src/routes/core/validate.rs diff --git a/server/src/routes/core/agenda.rs b/server/src/routes/core/agenda.rs new file mode 100644 index 0000000..618ae76 --- /dev/null +++ b/server/src/routes/core/agenda.rs @@ -0,0 +1,334 @@ +//! Agenda des publications (portage de la partie serveur de `shared/agenda.ts`) : +//! ce qui est déjà prévu, jour par jour et magasin par magasin, pour aider à +//! choisir la date d'un nouveau post ou Reel. +//! +//! Il réunit les publications planifiées (`scheduled_posts`) et les Reels encore +//! en montage, qui n'ont pas de ligne `scheduled_posts` avant la fin du rendu. + +use std::collections::HashMap; + +use serde::Serialize; +use serde_json::Value; + +use crate::db::{RenderingReelJob, ScheduledPostWithDetails}; +use crate::time::Ts; + +const PREVIEW_LENGTH: usize = 80; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AgendaStatus { + Scheduled, + Rendering, + Published, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgendaItem { + pub id: String, + pub post_id: String, + pub page_id: String, + pub page_name: String, + pub platform: String, + pub avatar_url: Option, + pub post_type: String, + /// ISO 8601. + pub scheduled_at: String, + pub status: AgendaStatus, + pub preview: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgendaPage { + pub id: String, + pub page_name: String, + pub platform: String, + pub avatar_url: Option, +} + +/// Texte d'aperçu : blancs réduits, coupé à 80 caractères (unités UTF-16). +fn preview(text: Option<&str>) -> String { + let flat = text + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" "); + if flat.encode_utf16().count() <= PREVIEW_LENGTH { + return flat; + } + let mut out = String::new(); + let mut units = 0; + for c in flat.chars() { + units += c.len_utf16(); + if units > PREVIEW_LENGTH - 1 { + break; + } + out.push(c); + } + out.push('…'); + out +} + +fn in_range(date: Ts, start: Option, end: Option) -> bool { + start.is_none_or(|s| date >= s) && end.is_none_or(|e| date <= e) +} + +/// Statut d'une publication planifiée : elle n'a pas de colonne dédiée. +pub fn scheduled_post_status(published_at: Option, error: Option<&str>) -> AgendaStatus { + if published_at.is_some() { + AgendaStatus::Published + } else if error.is_some_and(|e| !e.is_empty()) { + AgendaStatus::Failed + } else { + AgendaStatus::Scheduled + } +} + +pub fn agenda_item_from_scheduled_post(row: &ScheduledPostWithDetails) -> AgendaItem { + let sp = &row.scheduled; + AgendaItem { + id: sp.id.clone(), + post_id: sp.post_id.clone(), + page_id: sp.page_id.clone(), + page_name: row + .page + .as_ref() + .map(|p| p.page_name.clone()) + .unwrap_or_else(|| "Page supprimée".into()), + platform: row + .page + .as_ref() + .map(|p| p.platform.as_str().to_string()) + .unwrap_or_else(|| "facebook".into()), + avatar_url: row.page.as_ref().and_then(|p| p.avatar_url.clone()), + post_type: sp.post_type.as_str().to_string(), + scheduled_at: sp.scheduled_at.to_iso(), + status: scheduled_post_status(sp.published_at, sp.error.as_deref()), + preview: preview(Some(&row.post.content)), + } +} + +/// Entrées d'un Reel en montage, une par page visée et visible de l'utilisateur. +/// Sans date demandée, il part dès la fin du rendu : on le place à sa création. +pub fn agenda_items_from_reel_job( + job: &RenderingReelJob, + pages_by_id: &HashMap, + start: Option, + end: Option, +) -> Vec { + let Some(post_id) = &job.post_id else { + return Vec::new(); + }; + let params = &job.params; + let page_ids: Vec<&str> = params + .get("pageIds") + .and_then(Value::as_array) + .map(|ids| ids.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + let requested = params + .get("scheduledFor") + .and_then(Value::as_str) + .and_then(Ts::parse); + let when = requested.unwrap_or(job.created_at); + if !in_range(when, start, end) { + return Vec::new(); + } + + let param_text = |key: &str| { + params + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + }; + let text = job + .content + .as_deref() + .filter(|c| !c.is_empty()) + .or_else(|| param_text("description")) + .or_else(|| param_text("overlayText")); + + page_ids + .into_iter() + .filter_map(|page_id| { + let page = pages_by_id.get(page_id)?; + Some(AgendaItem { + id: format!("{}:{page_id}", job.id), + post_id: post_id.clone(), + page_id: page_id.to_string(), + page_name: page.page_name.clone(), + platform: page.platform.clone(), + avatar_url: page.avatar_url.clone(), + post_type: "reel".into(), + scheduled_at: when.to_iso(), + status: AgendaStatus::Rendering, + preview: preview(text), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{ + Platform, PostStatus, PostType, ScheduledPost, ScheduledPostPage, ScheduledPostPost, + }; + use serde_json::json; + + fn page(id: &str, name: &str, platform: &str) -> AgendaPage { + AgendaPage { + id: id.into(), + page_name: name.into(), + platform: platform.into(), + avatar_url: None, + } + } + + fn pages_by_id() -> HashMap { + [ + page("a", "Magasin A", "facebook"), + page("b", "Magasin B", "tiktok"), + ] + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect() + } + + fn ts(s: &str) -> Ts { + Ts::parse(s).unwrap() + } + + fn row(published_at: Option, error: Option<&str>) -> ScheduledPostWithDetails { + ScheduledPostWithDetails { + scheduled: ScheduledPost { + id: "s".into(), + post_id: "p".into(), + page_id: "a".into(), + post_type: PostType::Reel, + scheduled_at: ts("2026-10-14T18:00:00Z"), + published_at, + external_post_id: None, + error: error.map(str::to_string), + publish_id: None, + publish_status: None, + }, + post: ScheduledPostPost { + id: "p".into(), + user_id: "u".into(), + content: "Promo".into(), + status: PostStatus::Scheduled, + scheduled_for: None, + ai_generated: "false".into(), + generation_status: None, + generation_progress: None, + created_at: None, + }, + page: Some(ScheduledPostPage { + id: "a".into(), + page_id: "fb-a".into(), + page_name: "Magasin A".into(), + platform: Platform::Facebook, + avatar_url: None, + }), + } + } + + fn job(params: Value, created_at: Ts) -> RenderingReelJob { + RenderingReelJob { + id: "j".into(), + post_id: Some("p".into()), + created_at, + params, + content: None, + } + } + + #[test] + fn status_from_publication_columns() { + assert_eq!( + agenda_item_from_scheduled_post(&row(None, None)).status, + AgendaStatus::Scheduled + ); + assert_eq!( + agenda_item_from_scheduled_post(&row(Some(Ts::now()), None)).status, + AgendaStatus::Published + ); + assert_eq!( + agenda_item_from_scheduled_post(&row(None, Some("boom"))).status, + AgendaStatus::Failed + ); + let item = agenda_item_from_scheduled_post(&row(None, None)); + assert_eq!(item.preview, "Promo"); + assert_eq!(item.scheduled_at, "2026-10-14T18:00:00.000Z"); + let mut orphan = row(None, None); + orphan.page = None; + let item = agenda_item_from_scheduled_post(&orphan); + assert_eq!( + (item.page_name.as_str(), item.platform.as_str()), + ("Page supprimée", "facebook") + ); + } + + #[test] + fn one_rendering_entry_per_visible_store() { + let when = ts("2026-10-14T18:00:00Z"); + let items = agenda_items_from_reel_job( + &job( + json!({ "pageIds": ["a", "b", "inconnu"], "scheduledFor": when.to_iso() }), + Ts::now(), + ), + &pages_by_id(), + None, + None, + ); + assert_eq!( + items.iter().map(|i| i.page_id.as_str()).collect::>(), + ["a", "b"] + ); + assert!(items + .iter() + .all(|i| i.status == AgendaStatus::Rendering && i.post_type == "reel")); + assert_eq!(items[0].scheduled_at, when.to_iso()); + assert_eq!(items[0].id, "j:a"); + } + + #[test] + fn reel_without_date_is_placed_at_creation_and_respects_period() { + let created_at = ts("2026-10-12T09:00:00Z"); + let j = job(json!({ "pageIds": ["a"] }), created_at); + assert_eq!( + agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].scheduled_at, + created_at.to_iso() + ); + assert!(agenda_items_from_reel_job( + &j, + &pages_by_id(), + Some(ts("2026-10-13T00:00:00Z")), + None + ) + .is_empty()); + let mut no_post = j.clone(); + no_post.post_id = None; + assert!(agenda_items_from_reel_job(&no_post, &pages_by_id(), None, None).is_empty()); + } + + #[test] + fn preview_text() { + let j = job( + json!({ "pageIds": ["a"], "description": " Belle\n promo " }), + Ts::now(), + ); + assert_eq!( + agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].preview, + "Belle promo" + ); + let long = "x".repeat(100); + let p = preview(Some(&long)); + assert_eq!(p.chars().count(), 80); + assert!(p.ends_with('…')); + assert_eq!(preview(Some(&"y".repeat(80))), "y".repeat(80)); + } +} diff --git a/server/src/routes/core/ai.rs b/server/src/routes/core/ai.rs new file mode 100644 index 0000000..921073f --- /dev/null +++ b/server/src/routes/core/ai.rs @@ -0,0 +1,187 @@ +//! Génération de textes par IA et configuration d'OpenRouter. + +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Issues, Presence}; +use crate::auth::{AdminUser, AuthUser}; +use crate::db::Patch; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::Body; +use crate::services::openrouter; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/ai/models", get(models)) + .route("/api/ai/generate", post(generate)) + .route("/api/ai/generations", get(generations)) + .route("/api/openrouter/models", get(openrouter_models)) + .route("/api/openrouter/config", get(get_config).post(save_config)) +} + +async fn models(State(state): State, _admin: AdminUser) -> ApiResult> { + let models = openrouter::get_available_models(&state) + .await + .or_500("Failed to fetch models")?; + Ok(Json(json!({ "models": models }))) +} + +async fn generate( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + // `{ model, ...productInfo } = req.body` + let mut product_info = match body { + Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let model = product_info.remove("model"); + let model = model.as_ref().and_then(Value::as_str); + let product_info = Value::Object(product_info); + + let result = async { + let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model).await?; + state + .db + .create_ai_generation(&user.id, &product_info, &Value::Array(texts.clone())) + .await?; + anyhow::Ok(texts) + } + .await; + + match result { + Ok(texts) => Ok(Json(json!({ "variants": texts }))), + Err(error) => { + tracing::error!("Error generating text: {error:#}"); + let (status, message) = openrouter::describe_generation_error(&error); + Err(AppError::new(status, message)) + } + } +} + +async fn generations(State(state): State, admin: AdminUser) -> ApiResult> { + let generations = state + .db + .get_ai_generations(&admin.id) + .await + .or_500("Failed to fetch AI generations")?; + Ok(Json(json!(generations))) +} + +/// Catalogue brut des modèles OpenRouter, relayé tel quel. +async fn openrouter_models( + State(state): State, + _user: AuthUser, +) -> ApiResult> { + let result = async { + let response = state + .http + .get("https://openrouter.ai/api/v1/models") + .send() + .await?; + if !response.status().is_success() { + anyhow::bail!("OpenRouter API error: {}", response.status().as_u16()); + } + Ok(response.json::().await?) + } + .await; + result.or_500("Failed to fetch OpenRouter models").map(Json) +} + +async fn get_config(State(state): State, admin: AdminUser) -> ApiResult> { + let config = state + .db + .get_openrouter_config(&admin.id) + .await + .or_500("Failed to fetch OpenRouter config")?; + // La clé API n'est jamais sérialisée + Ok(Json(json!(config))) +} + +async fn save_config( + State(state): State, + admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save OpenRouter config"; + let failed = |reason: String| { + tracing::error!("Error saving OpenRouter config: {reason}"); + AppError::internal(FAILED) + }; + + let existing = state + .db + .get_openrouter_config(&admin.id) + .await + .or_500(FAILED)?; + + // Schéma zod : clé API « trim().min(1) », modèle et prompt texte facultatifs + let mut issues = Issues::new(); + let presence = if existing.is_some() { + Presence::Optional + } else { + Presence::Required + }; + let api_key = issues + .string(&body, "apiKey", presence) + .into_option() + .map(|k| k.trim().to_string()); + if api_key.as_deref() == Some("") { + return Err(failed("apiKey : La clé API ne peut pas être vide".into())); + } + let model = issues + .string(&body, "model", Presence::Optional) + .into_option(); + let system_prompt = issues + .string(&body, "systemPrompt", Presence::Optional) + .into_option(); + if !issues.is_empty() { + return Err(failed(issues.zod_message())); + } + + let config = match existing { + Some(existing) => { + let api_key = api_key + .filter(|k| !k.is_empty()) + .unwrap_or(existing.api_key); + if api_key.is_empty() { + return Err(AppError::bad_request( + "Clé API OpenRouter manquante. Veuillez entrer une clé API valide.", + )); + } + let mut patch = Patch::new() + .set("user_id", admin.id.as_str()) + .set("api_key", api_key); + if let Some(model) = model { + patch = patch.set("model", model); + } + if let Some(prompt) = system_prompt { + patch = patch.set("system_prompt", prompt); + } + state + .db + .update_openrouter_config(&admin.id, patch) + .await + .map_err(|e| failed(e.to_string()))? + .ok_or_else(|| failed("configuration introuvable".into()))? + } + None => { + let api_key = api_key.unwrap_or_default(); + state + .db + .create_openrouter_config( + &admin.id, + &api_key, + model.as_deref(), + system_prompt.as_deref(), + ) + .await + .map_err(|e| failed(e.to_string()))? + } + }; + Ok(Json(json!(config))) +} diff --git a/server/src/routes/core/audio.rs b/server/src/routes/core/audio.rs new file mode 100644 index 0000000..0ad3107 --- /dev/null +++ b/server/src/routes/core/audio.rs @@ -0,0 +1,168 @@ +//! Bibliothèque de pistes audio (envoi réservé aux administrateurs). +//! Les fichiers sont rangés directement dans le dossier audio persistant, +//! servi sous `/uploads/audio/`. + +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; + +use crate::auth::{AdminUser, AuthUser}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::services::audio::{fix_audio_track_encoding, probe_duration_secs}; +use crate::state::AppState; +use crate::upload::{self, UploadRules}; + +const ALLOWED_AUDIO_MIME_TYPES: [&str; 4] = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]; + +/// 50 Mo par fichier, 20 fichiers par envoi. +const MAX_AUDIO_SIZE: u64 = 50 * 1024 * 1024; +const MAX_AUDIO_FILES: usize = 20; + +pub fn router() -> Router { + Router::new() + .route( + "/api/audio-tracks", + get(list_tracks) + .post(upload_tracks) + .layer(DefaultBodyLimit::disable()), + ) + .route("/api/audio-tracks/fix-encoding", post(fix_encoding)) + .route("/api/audio-tracks/{id}", delete(delete_track)) +} + +fn audio_filter(mime: &str, name: &str) -> Result<(), String> { + static AUDIO_EXT: Lazy = Lazy::new(|| Regex::new(r"(?i)\.(mp3|wav|ogg)$").unwrap()); + if ALLOWED_AUDIO_MIME_TYPES.contains(&mime) || AUDIO_EXT.is_match(name) { + Ok(()) + } else { + Err(format!("Type de fichier audio non autorisé: {mime}")) + } +} + +/// Titre d'une piste : nom du fichier sans son extension. +fn title_from_file_name(name: &str) -> &str { + match name.rfind('.') { + Some(pos) if !name[pos + 1..].is_empty() && !name[pos + 1..].contains('/') => &name[..pos], + _ => name, + } +} + +async fn upload_tracks( + State(state): State, + admin: AdminUser, + multipart: Multipart, +) -> ApiResult> { + let dir = state.config.audio_upload_dir.clone(); + let (files, _) = upload::receive_files( + multipart, + UploadRules { + fields: &["files"], + dir: &dir, + max_file_size: MAX_AUDIO_SIZE, + max_files: MAX_AUDIO_FILES, + filter: &audio_filter, + }, + ) + .await + .map_err(super::upload_error)?; + + if files.is_empty() { + return Err(AppError::bad_request("No audio file uploaded")); + } + + let mut results = Vec::with_capacity(files.len()); + for file in &files { + tracing::info!( + "🎵 Audio saved locally: {} ({:.2} MB)", + file.original_name, + file.size as f64 / 1024.0 / 1024.0 + ); + let url = format!("/uploads/audio/{}", file.file_name); + let duration = probe_duration_secs(&file.path).await; + let created = state + .db + .create_audio_track( + Some(&admin.id), + title_from_file_name(&file.original_name), + &file.original_name, + &url, + duration, + ) + .await; + match created { + Ok(track) => results.push(json!({ "success": true, "track": track })), + Err(error) => { + // Fichier retiré si l'enregistrement en base échoue + let _ = tokio::fs::remove_file(&file.path).await; + results.push(json!({ + "success": false, + "fileName": file.original_name, + "error": super::pg_message(&error), + })); + } + } + } + Ok(Json(json!({ "results": results }))) +} + +async fn fix_encoding(State(state): State, _admin: AdminUser) -> ApiResult> { + fix_audio_track_encoding(&state).await; + let tracks = state + .db + .get_audio_tracks() + .await + .or_500("Failed to fix encoding")?; + Ok(Json(json!({ "success": true, "tracks": tracks }))) +} + +/// Accessible à tous : les pistes servent à habiller les Reels. +async fn list_tracks(State(state): State, _user: AuthUser) -> ApiResult> { + let tracks = state + .db + .get_audio_tracks() + .await + .or_500("Failed to fetch audio tracks")?; + Ok(Json(json!(tracks))) +} + +async fn delete_track( + State(state): State, + _admin: AdminUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete audio track"; + if state + .db + .get_audio_track(&id) + .await + .or_500(FAILED)? + .is_none() + { + return Err(AppError::not_found("Audio track not found")); + } + // Comme avant, le fichier local est conservé : seule la piste est retirée + state.db.delete_audio_track(&id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titles_and_filter() { + assert_eq!(title_from_file_name("Été 2024.mp3"), "Été 2024"); + assert_eq!(title_from_file_name("a.b.wav"), "a.b"); + assert_eq!(title_from_file_name("sans_extension"), "sans_extension"); + assert_eq!(title_from_file_name("fin."), "fin."); + assert!(audio_filter("application/octet-stream", "x.OGG").is_ok()); + assert!(audio_filter("audio/mpeg", "x").is_ok()); + assert_eq!( + audio_filter("video/mp4", "x.mp4").unwrap_err(), + "Type de fichier audio non autorisé: video/mp4" + ); + } +} diff --git a/server/src/routes/core/configs.rs b/server/src/routes/core/configs.rs new file mode 100644 index 0000000..8f8a4f4 --- /dev/null +++ b/server/src/routes/core/configs.rs @@ -0,0 +1,253 @@ +//! Configurations globales : stockage des médias (ex-Cloudinary) et logo de +//! l'entreprise, service FFmpeg. + +use axum::extract::{DefaultBodyLimit, Multipart, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::{AdminUser, AuthUser}; +use crate::db::{CloudinaryConfig, Patch, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body}; +use crate::services::{ffmpeg, media_store}; +use crate::state::AppState; +use crate::upload; + +pub fn router() -> Router { + Router::new() + .route( + "/api/cloudinary/config", + get(get_storage_config).post(save_storage_config), + ) + .route( + "/api/cloudinary/logo", + post(upload_logo) + .delete(delete_logo) + .layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/ffmpeg/config", + get(get_ffmpeg_config).post(save_ffmpeg_config), + ) +} + +/// Configuration sans le secret, avec l'URL du logo. +async fn get_storage_config( + State(state): State, + _user: AuthUser, +) -> ApiResult> { + let Some(config) = state + .db + .get_cloudinary_config() + .await + .or_500("Failed to fetch Cloudinary config")? + else { + return Ok(Json(Value::Null)); + }; + let logo_url = config + .logo_public_id + .as_deref() + .filter(|id| !id.is_empty()) + .map(media_store::build_url); + let mut value = json!(config); + if let Value::Object(map) = &mut value { + map.insert("logoUrl".into(), json!(logo_url)); + } + Ok(Json(value)) +} + +fn set_optional_text(patch: Patch, column: &'static str, field: Field) -> Patch { + match field { + Field::Absent => patch, + Field::Null => patch.set(column, Val::Text(None)), + Field::Set(value) => patch.set(column, value), + } +} + +async fn save_storage_config( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save Cloudinary config"; + let existing = state.db.get_cloudinary_config().await.or_500(FAILED)?; + + // Mise à jour : clé et secret facultatifs (les anciens sont conservés) + let secrets = if existing.is_some() { + Presence::Optional + } else { + Presence::Required + }; + let mut issues = Issues::new(); + let cloud_name = issues.string(&body, "cloudName", Presence::Required); + let api_key = issues.string(&body, "apiKey", secrets); + let api_secret = issues.string(&body, "apiSecret", secrets); + let logo_public_id = issues.string(&body, "logoPublicId", Presence::Nullable); + let endpoint_url = issues.string(&body, "endpointUrl", Presence::Nullable); + let public_url = issues.string(&body, "publicUrl", Presence::Nullable); + if !issues.is_empty() { + tracing::error!("Error saving Cloudinary config: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + let cloud_name = cloud_name.into_option().unwrap_or_default(); + + let config: CloudinaryConfig = match existing { + Some(existing) => { + let api_key = api_key + .into_option() + .filter(|k| !k.is_empty()) + .unwrap_or(existing.api_key); + let api_secret = api_secret + .into_option() + .filter(|s| !s.is_empty()) + .unwrap_or(existing.api_secret); + let mut patch = Patch::new() + .set("cloud_name", cloud_name) + .set("api_key", api_key) + .set("api_secret", api_secret); + patch = set_optional_text(patch, "logo_public_id", logo_public_id); + patch = set_optional_text(patch, "endpoint_url", endpoint_url); + patch = set_optional_text(patch, "public_url", public_url); + state + .db + .update_cloudinary_config(patch) + .await + .or_500(FAILED)? + } + None => state + .db + .create_cloudinary_config( + &cloud_name, + &api_key.into_option().unwrap_or_default(), + &api_secret.into_option().unwrap_or_default(), + logo_public_id.into_option().as_deref(), + endpoint_url.into_option().as_deref(), + public_url.into_option().as_deref(), + ) + .await + .or_500(FAILED)?, + }; + // Le secret n'est jamais sérialisé + Ok(Json(json!(config))) +} + +async fn upload_logo( + State(state): State, + _admin: AdminUser, + multipart: Multipart, +) -> ApiResult> { + let files = super::media::receive_media(multipart, "logo").await?; + let Some(file) = files.first().cloned() else { + return Err(AppError::bad_request("No logo file uploaded")); + }; + let result = store_logo(&state, &file).await; + upload::cleanup(&files).await; + match result { + Ok(value) => Ok(Json(value)), + Err(error) => { + tracing::error!("Error uploading logo: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +async fn store_logo(state: &AppState, file: &upload::UploadedFile) -> anyhow::Result { + // Le stockage local n'a pas besoin d'identifiants : ligne créée au besoin + let config = match state.db.get_cloudinary_config().await? { + Some(config) => config, + None => { + state + .db + .create_cloudinary_config("local", "local", "local", None, None, None) + .await? + } + }; + if let Some(old) = config.logo_public_id.as_deref().filter(|id| !id.is_empty()) { + if let Err(error) = media_store::delete_logo(state, old).await { + tracing::warn!("Failed to delete old logo: {error}"); + } + } + + let data = tokio::fs::read(&file.path).await?; + let _ = tokio::fs::remove_file(&file.path).await; + let (public_id, url) = media_store::upload_logo(state, &data, &file.original_name).await?; + state + .db + .update_cloudinary_config(Patch::new().set("logo_public_id", public_id.as_str())) + .await?; + Ok(json!({ "logoPublicId": public_id, "logoUrl": url })) +} + +async fn delete_logo(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_cloudinary_config().await.or_500_cause()?; + let Some(public_id) = config + .and_then(|c| c.logo_public_id) + .filter(|id| !id.is_empty()) + else { + return Err(AppError::not_found("No logo found")); + }; + media_store::delete_logo(&state, &public_id) + .await + .or_500_cause()?; + state + .db + .update_cloudinary_config(Patch::new().set("logo_public_id", Val::Text(None))) + .await + .or_500_cause()?; + Ok(Json(json!({ "success": true }))) +} + +async fn get_ffmpeg_config(State(state): State, _admin: AdminUser) -> Json { + let settings = state.ffmpeg.read().await; + let api_url = settings.api_url.clone().unwrap_or_default(); + let configured = + !api_url.is_empty() && settings.api_key.as_deref().is_some_and(|k| !k.is_empty()); + Json(json!({ "apiUrl": api_url, "configured": configured })) +} + +/// Reconfigure le service FFmpeg pour la durée de vie du serveur, puis le teste. +async fn save_ffmpeg_config( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save FFmpeg config"; + let api_url = body.get("apiUrl"); + if !is_truthy(api_url) { + return Err(AppError::bad_request("URL de l'API FFmpeg requise")); + } + let Some(api_url) = api_url.and_then(Value::as_str).map(str::to_string) else { + return Err(AppError::internal(FAILED)); + }; + + { + let mut settings = state.ffmpeg.write().await; + let api_key = match body.get("apiKey").filter(|k| is_truthy(Some(k))) { + Some(Value::String(key)) => key.clone(), + Some(_) => return Err(AppError::internal(FAILED)), + // Clé déjà configurée : on la garde + None => match settings.api_key.clone().filter(|k| !k.is_empty()) { + Some(key) => key, + None => { + return Err(AppError::bad_request( + "Clé API FFmpeg requise pour la première configuration", + )) + } + }, + }; + let url = api_url.strip_suffix('/').unwrap_or(&api_url).to_string(); + tracing::info!("🎬 FFmpeg Service configured: {url}"); + settings.api_url = Some(url); + settings.api_key = Some(api_key); + } + + let healthy = ffmpeg::health_check(&state).await; + Ok(Json(json!({ + "success": true, + "apiUrl": api_url, + "configured": true, + "healthy": healthy, + }))) +} diff --git a/server/src/routes/core/media.rs b/server/src/routes/core/media.rs new file mode 100644 index 0000000..fac8d25 --- /dev/null +++ b/server/src/routes/core/media.rs @@ -0,0 +1,247 @@ +//! Médiathèque : envoi, liste paginée, suppression. + +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::http::HeaderValue; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::db::{MediaType, NewMedia}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::QueryMap; +use crate::services::media_store::{self, MediaSource}; +use crate::services::thumbnail::{self, ThumbnailSource}; +use crate::state::AppState; +use crate::upload::{self, UploadRules, UploadedFile}; + +/// Types MIME autorisés pour les médias. +const ALLOWED_MIME_TYPES: [&str; 7] = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + "video/quicktime", + "video/webm", +]; + +/// 4 Go par fichier. +const MAX_MEDIA_SIZE: u64 = 4 * 1024 * 1024 * 1024; + +pub fn router() -> Router { + Router::new() + .route( + "/api/media/upload", + post(upload_media).layer(DefaultBodyLimit::disable()), + ) + .route("/api/media/fix-urls", post(fix_urls)) + .route("/api/media", get(list_media)) + .route("/api/media/{id}", delete(delete_media)) +} + +/// Filtre des médias (iOS envoie parfois `application/octet-stream`). +fn media_filter(mime: &str, _name: &str) -> Result<(), String> { + if ALLOWED_MIME_TYPES.contains(&mime) || mime == "application/octet-stream" { + Ok(()) + } else { + Err(format!("Type de fichier non autorisé: {mime}")) + } +} + +/// Réception d'un fichier média ou logo dans le dossier temporaire du système +/// (4 Go, 10 fichiers au plus) ; erreurs au format de multer. +pub(super) async fn receive_media( + multipart: Multipart, + field: &str, +) -> Result, AppError> { + let dir = std::env::temp_dir(); + let fields = [field]; + let (files, _) = upload::receive_files( + multipart, + UploadRules { + fields: &fields, + dir: &dir, + max_file_size: MAX_MEDIA_SIZE, + max_files: 10, + filter: &media_filter, + }, + ) + .await + .map_err(super::upload_error)?; + Ok(files) +} + +/// Vidéo d'après le type MIME ou l'extension. +fn is_video(mime: &str, name: &str) -> bool { + static VIDEO_EXT: Lazy = + Lazy::new(|| Regex::new(r"(?i)\.(mp4|mov|avi|mkv|webm)$").unwrap()); + mime.starts_with("video/") || VIDEO_EXT.is_match(name) +} + +async fn upload_media( + State(state): State, + user: AuthUser, + multipart: Multipart, +) -> ApiResult> { + let files = receive_media(multipart, "file").await?; + let Some(file) = files.first().cloned() else { + return Err(AppError::bad_request("No file uploaded")); + }; + + let result = store_upload(&state, &user.id, &file).await; + // Fichier temporaire supprimé dans tous les cas + upload::cleanup(&files).await; + match result { + Ok(media) => Ok(Json(json!(media))), + Err(error) => { + tracing::error!("Error uploading media: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +async fn store_upload( + state: &AppState, + user_id: &str, + file: &UploadedFile, +) -> anyhow::Result { + tracing::info!( + "📂 Processing upload: {} ({:.2} MB)", + file.original_name, + file.size as f64 / 1024.0 / 1024.0 + ); + let stored = + media_store::upload_media(state, MediaSource::Path(&file.path), &file.original_name) + .await?; + let video = is_video(&file.mime, &file.original_name); + + // Vignette extraite tant que la vidéo est sur le disque : elle survit à la + // suppression du fichier après publication (historique illustré) + let thumbnail_url = if video { + thumbnail::create_video_thumbnail(state, ThumbnailSource::Path(&file.path)).await + } else { + None + }; + + let file_size = i32::try_from(file.size) + .map_err(|_| anyhow::anyhow!("value \"{}\" is out of range for type integer", file.size))?; + let media = state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(if video { + MediaType::Video + } else { + MediaType::Image + }), + cloudinary_public_id: Some(stored.public_id), + original_url: stored.original_url, + facebook_landscape_url: None, + facebook_feed_url: stored.facebook_feed_url, + instagram_feed_url: stored.instagram_feed_url, + instagram_story_url: stored.instagram_story_url, + thumbnail_url, + file_name: file.original_name.clone(), + file_size, + }) + .await?; + Ok(media) +} + +/// Migration ponctuelle : URL absolues `http(s)://hôte/uploads/…` → relatives. +async fn fix_urls(State(state): State, user: AuthUser) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden("Admin only")); + } + let result = sqlx::query( + r#"UPDATE media SET + original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\1'), + facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\1'), + instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\1'), + instagram_story_url = regexp_replace(instagram_story_url, '^https?://[^/]+(/uploads/)', '\1') + WHERE + original_url ~ '^https?://[^/]+/uploads/' + OR facebook_feed_url ~ '^https?://[^/]+/uploads/' + OR instagram_feed_url ~ '^https?://[^/]+/uploads/' + OR instagram_story_url ~ '^https?://[^/]+/uploads/'"#, + ) + .execute(&state.db.pool) + .await + .or_500("Migration failed")?; + Ok(Json(json!({ "updated": result.rows_affected() }))) +} + +/// Sans `limit`, toute la médiathèque est renvoyée (les écrans qui retrouvent +/// un média par son identifiant en dépendent) ; avec, `X-Total-Count` donne le total. +async fn list_media( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult { + const FAILED: &str = "Failed to fetch media"; + let limit = super::parse_positive_int(query.get("limit")); + let offset = super::parse_positive_int(query.get("offset")); + let media = state + .db + .get_media(&user.id, limit, offset) + .await + .or_500(FAILED)?; + + let mut response = Json(json!(media)).into_response(); + if limit.is_some() { + let total = state.db.get_media_count(&user.id).await.or_500(FAILED)?; + if let Ok(value) = HeaderValue::from_str(&total.to_string()) { + response.headers_mut().insert("X-Total-Count", value); + } + } + Ok(response) +} + +async fn delete_media( + State(state): State, + user: AuthUser, + Path(media_id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete media"; + let media = state + .db + .get_media_by_id(&media_id) + .await + .or_500(FAILED)? + .filter(|m| m.user_id == user.id) + .ok_or_else(|| AppError::not_found("Media not found"))?; + + if let Some(public_id) = media + .cloudinary_public_id + .as_deref() + .filter(|p| !p.is_empty()) + { + media_store::delete_media(&state, public_id) + .await + .or_500(FAILED)?; + } + state.db.delete_media(&media_id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn video_detection_and_filter() { + assert!(is_video("video/mp4", "a.bin")); + assert!(is_video("application/octet-stream", "clip.MOV")); + assert!(!is_video("image/png", "a.png")); + assert!(media_filter("image/png", "a.png").is_ok()); + assert!(media_filter("application/octet-stream", "a").is_ok()); + assert_eq!( + media_filter("application/pdf", "a.pdf").unwrap_err(), + "Type de fichier non autorisé: application/pdf" + ); + } +} diff --git a/server/src/routes/core/mod.rs b/server/src/routes/core/mod.rs index dc02efc..223f961 100644 --- a/server/src/routes/core/mod.rs +++ b/server/src/routes/core/mod.rs @@ -1,9 +1,128 @@ -//! STUB — routes principales à porter depuis `server/routes.ts`. +//! Routes principales (portage des routes définies directement dans +//! `server/routes.ts`) : utilisateurs, paramètres, console SQL, statistiques, +//! IA, médias, pistes audio, posts, publications planifiées, pages et +//! configurations. +mod agenda; +mod ai; +mod audio; +mod configs; +mod media; +mod overlays; +mod pages; +mod posts; +mod scheduled; +mod settings; +mod sql; +mod stats; +mod users; +mod validate; + +use axum::http::StatusCode; use axum::Router; +use serde_json::{json, Value}; +use crate::error::AppError; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + .merge(users::router()) + .merge(settings::router()) + .merge(sql::router()) + .merge(stats::router()) + .merge(ai::router()) + .merge(media::router()) + .merge(overlays::router()) + .merge(audio::router()) + .merge(posts::router()) + .merge(scheduled::router()) + .merge(pages::router()) + .merge(configs::router()) +} + +/// `Number.parseInt(value, 10)` limité aux entiers positifs : `None` si la +/// valeur est absente, vide, illisible ou négative. +pub(crate) fn parse_positive_int(value: Option<&str>) -> Option { + let value = value.filter(|v| !v.is_empty())?; + // parseInt ignore les blancs de tête et s'arrête au premier caractère non numérique + let rest = value.trim_start(); + let (negative, digits) = match rest.as_bytes().first() { + Some(b'-') => (true, &rest[1..]), + Some(b'+') => (false, &rest[1..]), + _ => (false, rest), + }; + let end = digits.bytes().take_while(u8::is_ascii_digit).count(); + if end == 0 { + return None; + } + let parsed: i64 = digits[..end].parse().unwrap_or(i64::MAX); + if negative && parsed != 0 { + return None; + } + Some(parsed) +} + +/// Paramètre de date (`new Date(value)`), ignoré s'il est vide ou illisible. +pub(crate) fn parse_date_param(value: Option<&str>) -> Option { + value.filter(|v| !v.is_empty()).and_then(Ts::parse) +} + +/// Erreur de réception multer : elle passait par le gestionnaire d'erreurs +/// d'Express, qui répondait `500 { message }`. +pub(crate) fn upload_error(error: AppError) -> AppError { + let message = error + .body + .get("error") + .and_then(Value::as_str) + .unwrap_or("Internal Server Error"); + // multer : « Unexpected field » sans le nom du champ + let message = if message.starts_with("Unexpected field") { + "Unexpected field".to_string() + } else { + message.to_string() + }; + AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": message }), + ) +} + +/// Message d'une erreur sqlx tel que l'aurait donné le pilote `pg` +/// (message PostgreSQL brut pour une erreur de la base). +pub(crate) fn pg_message(error: &sqlx::Error) -> String { + match error { + sqlx::Error::Database(db) => db.message().to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positive_int_like_parse_int() { + assert_eq!(parse_positive_int(None), None); + assert_eq!(parse_positive_int(Some("")), None); + assert_eq!(parse_positive_int(Some("12")), Some(12)); + assert_eq!(parse_positive_int(Some(" 7abc")), Some(7)); + assert_eq!(parse_positive_int(Some("1e3")), Some(1)); + assert_eq!(parse_positive_int(Some("-3")), None); + assert_eq!(parse_positive_int(Some("-0")), Some(0)); + assert_eq!(parse_positive_int(Some("abc")), None); + } + + #[test] + fn date_param() { + assert!(parse_date_param(Some("")).is_none()); + assert!(parse_date_param(Some("nope")).is_none()); + assert_eq!( + parse_date_param(Some("2026-10-01T00:00:00.000Z")) + .unwrap() + .to_iso(), + "2026-10-01T00:00:00.000Z" + ); + } } diff --git a/server/src/routes/core/overlays.rs b/server/src/routes/core/overlays.rs new file mode 100644 index 0000000..f3c5506 --- /dev/null +++ b/server/src/routes/core/overlays.rs @@ -0,0 +1,611 @@ +//! Incrustations sur une image (bandeau d'angle, pastille de prix, logo), +//! calculées côté serveur puis enregistrées comme un nouveau média. +//! +//! Remplace le traitement sharp de l'ancienne route : les éléments vectoriels +//! sont décrits en SVG et rendus avec resvg, puis composés avec `image`. + +use std::io::Cursor; +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +use axum::extract::State; +use axum::routing::post; +use axum::{Json, Router}; +use image::codecs::jpeg::JpegEncoder; +use image::{DynamicImage, ImageDecoder, ImageReader, RgbaImage}; +use resvg::{tiny_skia, usvg}; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::db::{MediaType, NewMedia}; +use crate::error::{ApiResult, AppError}; +use crate::extract::{is_truthy, Body, JsonExt}; +use crate::services::media_store::{self, MediaSource}; +use crate::state::AppState; + +const RED: &str = "#dc2626"; +const YELLOW: &str = "#eab308"; +const RIBBON_SIZE: u32 = 150; +const BADGE_PADDING: f64 = 16.0; +const LOGO_PADDING: i64 = 20; + +pub fn router() -> Router { + Router::new().route("/api/media/apply-overlays", post(apply_overlays)) +} + +async fn apply_overlays( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + if !body.truthy("imageUrl") { + return Err(AppError::bad_request("Image URL required")); + } + match process(&state, &user.id, &body).await { + Ok(media) => Ok(Json(media)), + Err(error) => { + tracing::error!("💥 Error applying overlays: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +/// Paramètres d'incrustation lus dans la requête. +#[derive(Debug, Clone, Default)] +struct OverlaySpec { + ribbon: Option, + badge: Option, + logo: Option, +} + +#[derive(Debug, Clone)] +struct Ribbon { + text: String, + color: &'static str, + position: String, +} + +#[derive(Debug, Clone)] +struct PriceBadge { + text: String, + font_size: f64, + color: &'static str, + position: String, +} + +#[derive(Debug, Clone)] +struct LogoSpec { + data: Vec, + size: String, + position: String, + /// Opacité en pourcentage ; `None` = logo opaque. + opacity: Option, +} + +fn color_of(value: &Value) -> &'static str { + if value.str_field("color") == Some("red") { + RED + } else { + YELLOW + } +} + +/// Texte affiché d'une valeur JSON (`${value}` en JavaScript). +fn display(value: Option<&Value>) -> String { + match value { + None => "undefined".into(), + Some(Value::String(s)) => s.clone(), + Some(Value::Null) => "null".into(), + Some(other) => other.to_string(), + } +} + +async fn process(state: &AppState, user_id: &str, body: &Value) -> anyhow::Result { + let image_url = display(body.get("imageUrl")); + tracing::info!("🎨 Applying overlays server-side..."); + tracing::info!("📥 Image URL: {image_url}"); + + let image = media_store::read_media_buffer(state, &image_url).await?; + tracing::info!("✅ Image downloaded: {} bytes", image.len()); + + let mut spec = OverlaySpec::default(); + if let Some(ribbon) = body.get("ribbon").filter(|r| is_truthy(Some(r))) { + let Some(text) = ribbon.str_field("text") else { + anyhow::bail!("Cannot read properties of undefined (reading 'length')"); + }; + spec.ribbon = Some(Ribbon { + text: text.to_string(), + color: color_of(ribbon), + position: ribbon.str_field("position").unwrap_or_default().to_string(), + }); + } + if let Some(badge) = body.get("priceBadge").filter(|b| is_truthy(Some(b))) { + spec.badge = Some(PriceBadge { + text: format!("{} €", display(badge.get("price"))), + font_size: badge.get("size").and_then(Value::as_f64).unwrap_or(20.0), + color: color_of(badge), + position: badge.str_field("position").unwrap_or_default().to_string(), + }); + } + if let Some(logo) = body.get("logo").filter(|l| l.truthy("enabled")) { + spec.logo = load_logo(state, logo).await; + } + + let fonts_dir = state.config.fonts_dir.clone(); + let output = tokio::task::spawn_blocking(move || render(&image, &spec, &fonts_dir)).await??; + + tracing::info!("✅ Overlays applied, uploading to local storage..."); + let file_name = format!("edited_{}.jpg", chrono::Utc::now().timestamp_millis()); + let stored = media_store::upload_media(state, MediaSource::Bytes(&output), &file_name).await?; + + // Image retouchée : toutes les déclinaisons pointent sur l'original incrusté + let media = state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(MediaType::Image), + cloudinary_public_id: Some(stored.public_id), + original_url: stored.original_url.clone(), + facebook_landscape_url: None, + facebook_feed_url: Some(stored.original_url.clone()), + instagram_feed_url: Some(stored.original_url.clone()), + instagram_story_url: Some(stored.original_url), + thumbnail_url: None, + file_name, + file_size: i32::try_from(output.len()).unwrap_or(i32::MAX), + }) + .await?; + tracing::info!("✅ Image saved with ID: {}", media.id); + Ok(json!(media)) +} + +/// Logo de l'entreprise configuré dans les paramètres ; `None` (sans faire +/// échouer la requête) s'il est absent ou illisible. +async fn load_logo(state: &AppState, logo: &Value) -> Option { + tracing::info!("🏢 Adding logo overlay..."); + let config = match state.db.get_cloudinary_config().await { + Ok(config) => config, + Err(error) => { + tracing::error!("⚠️ Failed to apply logo: {error}"); + return None; + } + }; + let Some(public_id) = config + .and_then(|c| c.logo_public_id) + .filter(|p| !p.is_empty()) + else { + tracing::info!("⚠️ No logo configured in settings"); + return None; + }; + let url = media_store::build_url(&public_id); + tracing::info!("📥 Downloading logo from: {url}"); + match media_store::read_media_buffer(state, &url).await { + Ok(data) => Some(LogoSpec { + data: data.to_vec(), + size: logo.str_field("size").unwrap_or("medium").to_string(), + position: logo.str_field("position").unwrap_or_default().to_string(), + opacity: logo.get("opacity").and_then(Value::as_f64), + }), + Err(error) => { + tracing::error!("⚠️ Failed to apply logo: {error}"); + None + } + } +} + +/// Décode une image en appliquant son orientation EXIF (`sharp().rotate()`). +fn decode_oriented(data: &[u8]) -> anyhow::Result { + let mut decoder = ImageReader::new(Cursor::new(data)) + .with_guessed_format()? + .into_decoder()?; + let orientation = decoder.orientation().ok(); + let mut image = DynamicImage::from_decoder(decoder)?; + if let Some(orientation) = orientation { + image.apply_orientation(orientation); + } + Ok(image) +} + +fn render(data: &[u8], spec: &OverlaySpec, fonts_dir: &Path) -> anyhow::Result> { + let mut base: RgbaImage = decode_oriented(data)?.to_rgba8(); + let (width, height) = base.dimensions(); + tracing::info!("📐 Image dimensions: {width}x{height}"); + + if let Some(ribbon) = &spec.ribbon { + let geometry = ribbon_geometry(&ribbon.position, width, height); + let svg = ribbon_svg(ribbon, &geometry); + let layer = rasterize(&svg, fonts_dir)?; + image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y); + } + + if let Some(badge) = &spec.badge { + let geometry = badge_geometry(&badge.text, badge.font_size, &badge.position, width, height); + let svg = badge_svg(badge, &geometry); + let layer = rasterize(&svg, fonts_dir)?; + image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y); + } + + if let Some(logo) = &spec.logo { + match logo_layer(logo, width, height) { + Ok((layer, x, y)) => { + image::imageops::overlay(&mut base, &layer, x, y); + tracing::info!( + "✅ Logo added at position {} with {}% opacity", + logo.position, + logo.opacity.unwrap_or(100.0) + ); + } + Err(error) => tracing::error!("⚠️ Failed to apply logo: {error}"), + } + } + + let rgb = DynamicImage::ImageRgba8(base).to_rgb8(); + let mut output = Vec::new(); + JpegEncoder::new_with_quality(&mut output, 95).encode_image(&rgb)?; + Ok(output) +} + +/// Bandeau triangulaire d'angle (150 px) et son texte incliné. +#[derive(Debug, Clone, PartialEq)] +struct RibbonGeometry { + x: i64, + y: i64, + polygon: String, + text_x: f64, + text_y: f64, + rotation: i32, +} + +fn ribbon_geometry(position: &str, width: u32, height: u32) -> RibbonGeometry { + let s = RIBBON_SIZE as f64; + let right = width as i64 - RIBBON_SIZE as i64; + let bottom = height as i64 - RIBBON_SIZE as i64; + let size = RIBBON_SIZE; + let (x, y, polygon, text_x, text_y, rotation) = match position { + "north_east" => ( + right, + 0, + format!("0,0 {size},0 {size},{size}"), + s * 0.7, + s * 0.3, + 45, + ), + "south_west" => ( + 0, + bottom, + format!("0,0 0,{size} {size},{size}"), + s * 0.3, + s * 0.7, + -135, + ), + "south_east" => ( + right, + bottom, + format!("{size},0 0,{size} {size},{size}"), + s * 0.7, + s * 0.7, + 135, + ), + // north_west et toute valeur inconnue + _ => ( + 0, + 0, + format!("0,0 {size},0 0,{size}"), + s * 0.3, + s * 0.3, + -45, + ), + }; + RibbonGeometry { + x, + y, + polygon, + text_x, + text_y, + rotation, + } +} + +/// Taille du texte du bandeau selon sa longueur (JavaScript). +fn ribbon_font_size(text: &str) -> u32 { + match text.encode_utf16().count() { + 0..=5 => 22, + 6..=8 => 18, + 9..=11 => 15, + _ => 12, + } +} + +/// Échappe un texte pour l'insérer dans du XML. +fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +fn ribbon_svg(ribbon: &Ribbon, g: &RibbonGeometry) -> String { + format!( + r#"{text}"#, + size = RIBBON_SIZE, + polygon = g.polygon, + color = ribbon.color, + tx = g.text_x, + ty = g.text_y, + font = ribbon_font_size(&ribbon.text), + rot = g.rotation, + text = xml_escape(&ribbon.text), + ) +} + +/// Pastille de prix : largeur estimée d'après le nombre de caractères. +#[derive(Debug, Clone, PartialEq)] +struct BadgeGeometry { + width: f64, + height: f64, + x: i64, + y: i64, +} + +fn badge_geometry( + text: &str, + font_size: f64, + position: &str, + width: u32, + height: u32, +) -> BadgeGeometry { + let text_width = text.encode_utf16().count() as f64 * font_size * 0.6; + let badge_width = text_width + BADGE_PADDING * 2.0; + let badge_height = font_size + BADGE_PADDING; + let right = width as f64 - badge_width - BADGE_PADDING; + let bottom = height as f64 - badge_height - BADGE_PADDING; + let (x, y) = match position { + "north_east" => (right, BADGE_PADDING), + "south_west" => (BADGE_PADDING, bottom), + "south_east" => (right, bottom), + _ => (BADGE_PADDING, BADGE_PADDING), + }; + BadgeGeometry { + width: badge_width, + height: badge_height, + x: x.round() as i64, + y: y.round() as i64, + } +} + +fn badge_svg(badge: &PriceBadge, g: &BadgeGeometry) -> String { + format!( + r#"{text}"#, + w = g.width, + h = g.height, + r = g.height / 2.0, + cx = g.width / 2.0, + color = badge.color, + font = badge.font_size, + text = xml_escape(&badge.text), + ) +} + +/// Base de polices partagée : polices du système et dossier `fonts/`. +fn font_database(fonts_dir: &Path) -> Arc { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_system_fonts(); + db.load_fonts_dir(fonts_dir); + let has = |family: &str| { + db.faces() + .any(|face| face.families.iter().any(|(name, _)| name == family)) + }; + // « Arial, sans-serif » : repli sur une police réellement disponible + let fallback = ["Arial", "Liberation Sans", "DejaVu Sans", "FreeSans"] + .into_iter() + .find(|f| has(f)) + .map(str::to_string); + if let Some(family) = fallback { + db.set_sans_serif_family(family); + } + Arc::new(db) + }) + .clone() +} + +/// Rend un SVG en image RGBA (alpha non prémultiplié). +fn rasterize(svg: &str, fonts_dir: &Path) -> anyhow::Result { + let options = usvg::Options { + fontdb: font_database(fonts_dir), + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_str(svg, &options)?; + let size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| anyhow::anyhow!("dimensions SVG invalides"))?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + + let mut data = Vec::with_capacity(pixmap.data().len()); + for pixel in pixmap.pixels() { + let c = pixel.demultiply(); + data.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]); + } + RgbaImage::from_raw(size.width(), size.height(), data) + .ok_or_else(|| anyhow::anyhow!("rendu SVG invalide")) +} + +/// Largeur du logo : part de la largeur de l'image, sans dépasser les marges. +fn logo_width(size: &str, width: u32) -> i64 { + let ratio = match size { + "small" => 0.35, + "large" => 0.70, + _ => 0.50, + }; + let requested = (width as f64 * ratio).round() as i64; + requested.min(width as i64 - LOGO_PADDING * 2) +} + +fn logo_position(position: &str, width: u32, height: u32, logo_w: i64, logo_h: i64) -> (i64, i64) { + let (w, h) = (width as i64, height as i64); + let right = w - logo_w - LOGO_PADDING; + let bottom = h - logo_h - LOGO_PADDING; + match position { + "north_east" => (right, LOGO_PADDING), + "south_west" => (LOGO_PADDING, bottom), + "south_east" => (right, bottom), + "center" => ( + ((w - logo_w) as f64 / 2.0).round() as i64, + ((h - logo_h) as f64 / 2.0).round() as i64, + ), + _ => (LOGO_PADDING, LOGO_PADDING), + } +} + +fn logo_layer(logo: &LogoSpec, width: u32, height: u32) -> anyhow::Result<(RgbaImage, i64, i64)> { + let source = decode_oriented(&logo.data)?.to_rgba8(); + let target_w = logo_width(&logo.size, width); + if target_w <= 0 || source.width() == 0 { + anyhow::bail!("Expected positive integer for width but received {target_w}"); + } + let target_h = + ((source.height() as f64 * target_w as f64 / source.width() as f64).round() as u32).max(1); + let mut resized = image::imageops::resize( + &source, + target_w as u32, + target_h, + image::imageops::FilterType::Lanczos3, + ); + + if target_h as i64 > height as i64 - LOGO_PADDING * 2 { + tracing::warn!( + "⚠️ Logo height {target_h}px exceeds max {}px, would be clipped", + height as i64 - LOGO_PADDING * 2 + ); + } + + if let Some(opacity) = logo.opacity.filter(|o| *o < 100.0) { + let factor = (opacity / 100.0).clamp(0.0, 1.0); + for pixel in resized.pixels_mut() { + pixel[3] = (pixel[3] as f64 * factor).round() as u8; + } + } + + let (x, y) = logo_position(&logo.position, width, height, target_w, target_h as i64); + Ok((resized, x, y)) +} + +/// Chemin de polices utilisé par défaut dans les tests. +#[cfg(test)] +fn test_fonts_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ribbon_positions() { + let g = ribbon_geometry("south_east", 800, 600); + assert_eq!((g.x, g.y, g.rotation), (650, 450, 135)); + assert_eq!(g.polygon, "150,0 0,150 150,150"); + assert!((g.text_x - 105.0).abs() < 1e-9); + let g = ribbon_geometry("inconnu", 800, 600); + assert_eq!((g.x, g.y, g.rotation), (0, 0, -45)); + assert_eq!(ribbon_font_size("Promo"), 22); + assert_eq!(ribbon_font_size("Soldes!!"), 18); + assert_eq!(ribbon_font_size("Nouveauté!!"), 15); + assert_eq!(ribbon_font_size("Très grosse promo"), 12); + } + + #[test] + fn badge_positions() { + let g = badge_geometry("12 €", 20.0, "north_east", 800, 600); + assert_eq!((g.width, g.height), (80.0, 36.0)); + assert_eq!((g.x, g.y), (704, 16)); + let g = badge_geometry("12 €", 20.0, "south_west", 800, 600); + assert_eq!((g.x, g.y), (16, 548)); + } + + #[test] + fn logo_sizes_and_positions() { + assert_eq!(logo_width("small", 1000), 350); + assert_eq!(logo_width("large", 1000), 700); + assert_eq!(logo_width("medium", 1000), 500); + // Plafonné aux marges + assert_eq!(logo_width("large", 100), 60); + assert_eq!(logo_position("south_east", 1000, 800, 500, 100), (480, 680)); + assert_eq!(logo_position("center", 1000, 800, 500, 101), (250, 350)); + assert_eq!(logo_position("north_west", 1000, 800, 500, 100), (20, 20)); + } + + #[test] + fn escapes_text() { + assert_eq!(xml_escape("A&B <\"c\">"), "A&B <"c">"); + let ribbon = Ribbon { + text: "".into(), + color: RED, + position: "north_west".into(), + }; + let svg = ribbon_svg(&ribbon, &ribbon_geometry("north_west", 300, 300)); + assert!(svg.contains("<b>")); + assert!(rasterize(&svg, &test_fonts_dir()).is_ok()); + } + + #[test] + fn renders_overlays_on_jpeg() { + let base = RgbaImage::from_pixel(400, 300, image::Rgba([0, 0, 255, 255])); + let mut jpeg = Vec::new(); + JpegEncoder::new_with_quality(&mut jpeg, 90) + .encode_image(&DynamicImage::ImageRgba8(base).to_rgb8()) + .unwrap(); + let mut png_logo = Vec::new(); + DynamicImage::ImageRgba8(RgbaImage::from_pixel( + 10, + 5, + image::Rgba([255, 255, 255, 255]), + )) + .write_to(&mut Cursor::new(&mut png_logo), image::ImageFormat::Png) + .unwrap(); + let spec = OverlaySpec { + ribbon: Some(Ribbon { + text: "Promo".into(), + color: RED, + position: "north_west".into(), + }), + badge: Some(PriceBadge { + text: "9,99 €".into(), + font_size: 20.0, + color: YELLOW, + position: "south_east".into(), + }), + logo: Some(LogoSpec { + data: png_logo, + size: "small".into(), + position: "center".into(), + opacity: Some(50.0), + }), + }; + let output = render(&jpeg, &spec, &test_fonts_dir()).unwrap(); + let out = image::load_from_memory(&output).unwrap().to_rgb8(); + assert_eq!(out.dimensions(), (400, 300)); + // Coin du bandeau rouge + let corner = out.get_pixel(5, 5); + assert!(corner[0] > 180 && corner[2] < 80, "{corner:?}"); + // Logo blanc à 50 % au centre : mélange bleu/blanc + let center = out.get_pixel(200, 150); + assert!( + center[0] > 90 && center[0] < 170 && center[2] > 200, + "{center:?}" + ); + } +} diff --git a/server/src/routes/core/pages.rs b/server/src/routes/core/pages.rs new file mode 100644 index 0000000..7940573 --- /dev/null +++ b/server/src/routes/core/pages.rs @@ -0,0 +1,240 @@ +//! Pages et comptes connectés (saisie manuelle d'un jeton Facebook/Instagram). + +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use chrono::Duration; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{NewSocialPage, Patch, Platform, TokenStatus, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::state::AppState; +use crate::time::Ts; + +/// Durée de validité supposée d'un jeton de page saisi à la main. +const TOKEN_LIFETIME_DAYS: i64 = 60; + +pub fn router() -> Router { + Router::new() + .route("/api/pages", get(list_pages).post(create_page)) + .route("/api/pages/{id}", put(update_page).delete(delete_page)) +} + +/// Les jetons restent au serveur : seule la vue client est renvoyée. +async fn list_pages(State(state): State, user: AuthUser) -> ApiResult> { + let pages = state + .db + .get_visible_pages(&user) + .await + .or_500("Failed to fetch pages")?; + Ok(Json(Value::Array( + pages.iter().map(|p| p.to_client()).collect(), + ))) +} + +const PLATFORMS: [&str; 3] = ["facebook", "instagram", "tiktok"]; + +/// Champs du schéma `insertSocialPageSchema` (hors `userId`), dans l'ordre +/// des colonnes ; `partial` rend tout facultatif (mise à jour). +struct PageFields { + user_id: Field, + platform: Field, + page_id: Field, + page_name: Field, + access_token: Field, + followers_count: Field, + is_active: Field, + user_access_token: Field, + refresh_token: Field, + scopes: Field, + avatar_url: Field, +} + +fn parse_page_fields(body: &Value, partial: bool, issues: &mut Issues) -> PageFields { + let required = if partial { + Presence::Optional + } else { + Presence::Required + }; + let user_id = if partial { + issues.string(body, "userId", Presence::Optional) + } else { + Field::Absent + }; + let platform = issues.enumeration(body, "platform", required, &PLATFORMS); + let page_id = issues.string(body, "pageId", required); + let page_name = issues.string(body, "pageName", required); + let access_token = issues.string(body, "accessToken", required); + if partial { + issues.date(body, "tokenExpiresAt", Presence::Nullable); + } + let followers_count = issues.int(body, "followersCount", Presence::Nullable); + let is_active = issues.string(body, "isActive", Presence::Optional); + let user_access_token = issues.string(body, "userAccessToken", Presence::Nullable); + issues.date(body, "userTokenExpiresAt", Presence::Nullable); + let refresh_token = issues.string(body, "refreshToken", Presence::Nullable); + issues.date(body, "refreshTokenExpiresAt", Presence::Nullable); + let scopes = issues.string(body, "scopes", Presence::Nullable); + let avatar_url = issues.string(body, "avatarUrl", Presence::Nullable); + PageFields { + user_id, + platform, + page_id, + page_name, + access_token, + followers_count, + is_active, + user_access_token, + refresh_token, + scopes, + avatar_url, + } +} + +async fn create_page( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to create page"; + + // Un compte TikTok passe obligatoirement par le flux OAuth (/api/tiktok/connect) + if body.str_field("platform") == Some("tiktok") { + return Err(AppError::bad_request( + "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.", + )); + } + + let mut issues = Issues::new(); + let fields = parse_page_fields(&body, false, &mut issues); + if !issues.is_empty() { + tracing::error!("Error creating page: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let page = state + .db + .create_social_page(NewSocialPage { + user_id: user.id.clone(), + platform: fields + .platform + .into_option() + .and_then(|p| Platform::parse(&p)), + page_id: fields.page_id.into_option().unwrap_or_default(), + page_name: fields.page_name.into_option().unwrap_or_default(), + access_token: fields.access_token.into_option().unwrap_or_default(), + token_expires_at: Some(Ts::now().add(Duration::days(TOKEN_LIFETIME_DAYS))), + followers_count: fields.followers_count.into_option(), + is_active: fields.is_active.into_option(), + user_access_token: fields.user_access_token.into_option(), + user_token_expires_at: None, + refresh_token: fields.refresh_token.into_option(), + refresh_token_expires_at: None, + scopes: fields.scopes.into_option(), + avatar_url: fields.avatar_url.into_option(), + }) + .await + .or_500(FAILED)?; + Ok(Json(page.to_client())) +} + +/// Ajoute un champ texte au patch s'il est présent (`null` compris). +fn set_text(patch: Patch, column: &'static str, field: Field) -> Patch { + match field { + Field::Absent => patch, + Field::Null => patch.set(column, Val::Text(None)), + Field::Set(value) => patch.set(column, value), + } +} + +async fn update_page( + State(state): State, + user: AuthUser, + Path(page_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update page"; + + let existing = state + .db + .get_social_page(&page_id) + .await + .or_500(FAILED)? + .filter(|p| p.user_id == user.id) + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + + let mut issues = Issues::new(); + let fields = parse_page_fields(&body, true, &mut issues); + if !issues.is_empty() { + tracing::error!("Error updating page: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let new_token = matches!(&fields.access_token, Field::Set(t) if !t.is_empty()); + let mut patch = Patch::new(); + patch = set_text(patch, "user_id", fields.user_id); + if let Field::Set(platform) = fields.platform.clone() { + if let Some(platform) = Platform::parse(&platform) { + patch = patch.set("platform", platform); + } + } + patch = set_text(patch, "page_id", fields.page_id); + patch = set_text(patch, "page_name", fields.page_name); + patch = set_text(patch, "access_token", fields.access_token); + match fields.followers_count { + Field::Absent => {} + Field::Null => patch = patch.set("followers_count", Val::Int(None)), + Field::Set(count) => patch = patch.set("followers_count", count), + } + patch = set_text(patch, "is_active", fields.is_active); + patch = set_text(patch, "user_access_token", fields.user_access_token); + patch = set_text(patch, "refresh_token", fields.refresh_token); + patch = set_text(patch, "scopes", fields.scopes); + patch = set_text(patch, "avatar_url", fields.avatar_url); + + // Nouveau jeton : échéance recalculée et statut remis à « valide » pour que + // l'interface se mette à jour (les jetons TikTok ont leur propre cycle) + if new_token && existing.platform != Platform::Tiktok { + let now = Ts::now(); + patch = patch + .set( + "token_expires_at", + now.add(Duration::days(TOKEN_LIFETIME_DAYS)), + ) + .set("token_status", TokenStatus::Valid) + .set("last_token_check", now); + } + + // Aucune valeur à écrire : l'ancienne mise à jour échouait + if patch.is_empty() { + tracing::error!("Error updating page: No values to set"); + return Err(AppError::internal(FAILED)); + } + let updated = state + .db + .update_social_page(&page_id, patch) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::internal(FAILED))?; + Ok(Json(updated.to_client())) +} + +async fn delete_page( + State(state): State, + user: AuthUser, + Path(page_id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete page"; + state + .db + .get_social_page(&page_id) + .await + .or_500(FAILED)? + .filter(|p| p.user_id == user.id) + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + state.db.delete_social_page(&page_id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} diff --git a/server/src/routes/core/posts.rs b/server/src/routes/core/posts.rs new file mode 100644 index 0000000..e37472f --- /dev/null +++ b/server/src/routes/core/posts.rs @@ -0,0 +1,488 @@ +//! Publications : création (avec médias et programmation par page), lecture, +//! modification du texte et des médias. + +use axum::extract::{Path, State}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{ + MediaType, NewPost, NewScheduledPost, Patch, Platform, Post, PostStatus, PostType, User, +}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, JsonExt}; +use crate::state::AppState; +use crate::time::Ts; + +/// Nombre maximal de médias par publication. +const MAX_MEDIA: usize = 10; + +pub fn router() -> Router { + Router::new() + .route("/api/posts", get(list_posts).post(create_post)) + .route("/api/posts/{id}", get(get_post).patch(update_post)) + .route("/api/posts/{id}/media", patch(update_post_media)) +} + +async fn list_posts(State(state): State, user: AuthUser) -> ApiResult> { + let posts = state + .db + .get_posts(&user.id) + .await + .or_500("Failed to fetch posts")?; + Ok(Json(json!(posts))) +} + +/// Texte d'une valeur JSON utilisée comme identifiant. +fn id_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Médias d'une publication, au format `(mediaId, displayOrder)` : +/// `mediaIds` (chaînes ou objets `{ mediaId, displayOrder }`) ou, à défaut, +/// l'ancien champ unique `mediaId`. +fn normalize_media_items( + media_ids: Option<&Value>, + media_id: Option<&Value>, +) -> Result, String> { + if let Some(Value::Array(items)) = media_ids { + return items + .iter() + .enumerate() + .map(|(index, item)| { + let index = index as i32; + match item { + Value::String(id) => Ok((id.clone(), index)), + Value::Object(_) if item.truthy("mediaId") => { + let order = match item.get("displayOrder") { + None | Some(Value::Null) => index, + Some(Value::Number(n)) => n.as_f64().map(|f| f as i32).unwrap_or(index), + Some(Value::String(s)) => s.trim().parse().unwrap_or(index), + Some(_) => index, + }; + Ok((id_text(&item["mediaId"]), order)) + } + _ => Err("Invalid media format".to_string()), + } + }) + .collect(); + } + match media_id { + Some(id) if is_truthy(Some(id)) => Ok(vec![(id_text(id), 0)]), + _ => Ok(Vec::new()), + } +} + +/// Champs du post validés (schéma `insertPostSchema`). +struct PostFields { + content: String, + ai_generated: Option, + product_info: Option, + status: PostStatus, + scheduled_for: Option, + generation_status: Option, + generation_progress: Option, + generation_error: Option, +} + +/// Valide les champs du post ; l'erreur est le message de `ZodError`. +fn parse_post_fields(body: &Value) -> Result { + let scheduled_raw = body.get("scheduledFor"); + let scheduled = is_truthy(scheduled_raw); + + let mut issues = Issues::new(); + let content = issues.string(body, "content", Presence::Required); + let ai_generated = issues.string(body, "aiGenerated", Presence::Optional); + let product_info = issues.json(body, "productInfo"); + // Une date de publication fait passer le post en « scheduled » + let status = if scheduled { + Field::Set("scheduled".to_string()) + } else { + issues.enumeration( + body, + "status", + Presence::Optional, + &["draft", "scheduled", "published", "failed"], + ) + }; + let scheduled_for = match scheduled_raw { + // `new Date(chaîne)` avant validation + Some(Value::String(s)) if !s.is_empty() => { + issues.converted_date("scheduledFor", Ts::parse(s)) + } + _ => issues + .date(body, "scheduledFor", Presence::Nullable) + .into_option(), + }; + let generation_status = issues.string(body, "generationStatus", Presence::Nullable); + let generation_progress = issues.int(body, "generationProgress", Presence::Nullable); + let generation_error = issues.string(body, "generationError", Presence::Nullable); + + if !issues.is_empty() { + return Err(issues.zod_message()); + } + Ok(PostFields { + content: content.into_option().unwrap_or_default(), + ai_generated: ai_generated.into_option(), + product_info: product_info.into_option(), + status: status + .into_option() + .and_then(|s| PostStatus::parse(&s)) + .unwrap_or(PostStatus::Draft), + scheduled_for, + generation_status: generation_status.into_option(), + generation_progress: generation_progress.into_option(), + generation_error: generation_error.into_option(), + }) +} + +async fn create_post( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + match create_post_inner(&state, &user, &body).await { + Ok(Ok(post)) => Ok(Json(json!(post))), + Ok(Err(response)) => Err(response), + Err(message) => { + tracing::error!("Error creating post: {message}"); + Err(AppError::internal(message)) + } + } +} + +/// `Err(message)` : erreur inattendue (500 avec le message) ; +/// `Ok(Err(réponse))` : refus explicite (400/403). +async fn create_post_inner( + state: &AppState, + user: &User, + body: &Value, +) -> Result, String> { + let db_error = |e: sqlx::Error| super::pg_message(&e); + + let media_items = normalize_media_items(body.get("mediaIds"), body.get("mediaId"))?; + let post_type = body.get("postType"); + let post_type_str = post_type.and_then(Value::as_str); + let page_ids: Option<&Vec> = body.get("pageIds").and_then(Value::as_array); + let has_pages = page_ids.is_some_and(|p| !p.is_empty()); + + if media_items.len() > MAX_MEDIA { + return Ok(Err(AppError::bad_request( + "Maximum 10 photos autorisées par publication", + ))); + } + if matches!(post_type_str, Some("story") | Some("both")) && media_items.is_empty() { + return Ok(Err(AppError::bad_request( + "Les stories nécessitent au moins un média (image ou vidéo)", + ))); + } + + // Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok + if post_type_str == Some("reel") { + if media_items.len() != 1 { + return Ok(Err(AppError::bad_request( + "Un Reel nécessite exactement une vidéo", + ))); + } + let media = state + .db + .get_media_by_id(&media_items[0].0) + .await + .map_err(db_error)?; + let valid = media.is_some_and(|m| { + m.media_type == MediaType::Video && (user.is_admin() || m.user_id == user.id) + }); + if !valid { + return Ok(Err(AppError::bad_request( + "Le média d'un Reel doit être une de vos vidéos", + ))); + } + for page_id in page_ids.into_iter().flatten() { + let page = state + .db + .get_social_page(&id_text(page_id)) + .await + .map_err(db_error)?; + if !page.is_some_and(|p| matches!(p.platform, Platform::Facebook | Platform::Tiktok)) { + return Ok(Err(AppError::bad_request( + "Les Reels ne peuvent être publiés que sur Facebook ou TikTok", + ))); + } + } + } + + // Un utilisateur ne publie que sur les pages qui lui sont attribuées + if !user.is_admin() && has_pages { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .map_err(db_error)?; + let all_accessible = page_ids.into_iter().flatten().all(|id| { + id.as_str() + .is_some_and(|id| accessible.iter().any(|p| p.id == id)) + }); + if !all_accessible { + return Ok(Err(AppError::forbidden( + "Vous n'avez pas accès à certaines pages sélectionnées", + ))); + } + } + + if is_truthy(body.get("scheduledFor")) && !has_pages { + return Ok(Err(AppError::bad_request( + "Les posts programmés nécessitent au moins une page cible", + ))); + } + + let fields = parse_post_fields(body)?; + let scheduled_for = fields.scheduled_for; + let mut new_post = NewPost::new(user.id.clone(), fields.content); + if let Some(ai) = fields.ai_generated { + new_post.ai_generated = ai; + } + new_post.product_info = fields.product_info; + new_post.status = fields.status; + new_post.scheduled_for = scheduled_for; + new_post.generation_status = fields.generation_status; + new_post.generation_progress = fields.generation_progress; + new_post.generation_error = fields.generation_error; + let post = state.db.create_post(new_post).await.map_err(db_error)?; + + if !media_items.is_empty() { + state + .db + .set_post_media(&post.id, &media_items, false) + .await + .map_err(db_error)?; + } + + if let Some(page_ids) = page_ids.filter(|p| !p.is_empty()) { + // Sans date : publication immédiate + let scheduled_at = scheduled_for.unwrap_or_else(Ts::now); + let type_text = match post_type { + Some(v) if is_truthy(Some(v)) => id_text(v), + _ => "feed".to_string(), + }; + let final_type = PostType::parse(&type_text) + .ok_or_else(|| format!("invalid input value for enum post_type: \"{type_text}\""))?; + let also_story = body.get("alsoStory") == Some(&Value::Bool(true)); + + for page_id in page_ids { + let page_id = id_text(page_id); + let create = |post_type| { + NewScheduledPost::new(post.id.clone(), page_id.clone(), post_type, scheduled_at) + }; + if final_type == PostType::Both { + // « both » : une story et un post de fil, indépendants + state + .db + .create_scheduled_post(create(PostType::Story)) + .await + .map_err(db_error)?; + state + .db + .create_scheduled_post(create(PostType::Feed)) + .await + .map_err(db_error)?; + } else { + state + .db + .create_scheduled_post(create(final_type)) + .await + .map_err(db_error)?; + // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas + if final_type == PostType::Reel && also_story { + let page = state.db.get_social_page(&page_id).await.map_err(db_error)?; + if page.is_some_and(|p| p.platform == Platform::Facebook) { + state + .db + .create_scheduled_post(create(PostType::Story)) + .await + .map_err(db_error)?; + } + } + } + } + } + + Ok(Ok(post)) +} + +fn post_with_media((post, media): (Post, Vec)) -> Value { + json!({ "post": post, "media": media }) +} + +async fn get_post( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch post"; + let found = state + .db + .get_post_with_media(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + + // Admin : tout ; propriétaire : son post + if user.is_admin() || found.0.user_id == user.id { + return Ok(Json(post_with_media(found))); + } + + // Utilisateur : les posts programmés sur une de ses pages + let scheduled = state + .db + .get_scheduled_posts_by_post(&id) + .await + .or_500(FAILED)?; + if !scheduled.is_empty() { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .or_500(FAILED)?; + if scheduled + .iter() + .any(|sp| accessible.iter().any(|p| p.id == sp.page_id)) + { + return Ok(Json(post_with_media(found))); + } + } + Err(AppError::forbidden("Unauthorized")) +} + +async fn update_post( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update post"; + let post = state + .db + .get_post(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + + let mut patch = Patch::new(); + match body.get("content") { + None => {} + Some(Value::String(content)) => patch = patch.set("content", content.as_str()), + // `null` ou autre type : refusé par la base (colonne texte non nulle) + Some(_) => return Err(AppError::internal(FAILED)), + } + let updated = state.db.update_post(&id, patch).await.or_500(FAILED)?; + Ok(Json(json!(updated))) +} + +async fn update_post_media( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update post media"; + let post = state + .db + .get_post(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + + let Some(media_ids) = body.get("mediaIds").and_then(Value::as_array) else { + return Err(AppError::bad_request("mediaIds must be an array")); + }; + if media_ids.len() > MAX_MEDIA { + return Err(AppError::bad_request( + "Maximum 10 photos autorisées par publication", + )); + } + let media_ids: Vec = media_ids.iter().map(id_text).collect(); + + state + .db + .update_post_media(&id, &media_ids) + .await + .or_500(FAILED)?; + let updated = state.db.get_post_with_media(&id).await.or_500(FAILED)?; + Ok(Json(updated.map(post_with_media).unwrap_or(Value::Null))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_list_normalization() { + let ids = json!(["a", { "mediaId": "b", "displayOrder": 5 }, { "mediaId": "c" }]); + assert_eq!( + normalize_media_items(Some(&ids), None).unwrap(), + vec![("a".into(), 0), ("b".into(), 5), ("c".into(), 2)] + ); + // Ancien champ unique + assert_eq!( + normalize_media_items(None, Some(&json!("m"))).unwrap(), + vec![("m".into(), 0)] + ); + // Un tableau prime sur mediaId ; un non-tableau est ignoré + assert_eq!( + normalize_media_items(Some(&json!([])), Some(&json!("m"))).unwrap(), + vec![] + ); + assert_eq!( + normalize_media_items(Some(&json!("x")), Some(&json!("m"))).unwrap(), + vec![("m".into(), 0)] + ); + assert_eq!( + normalize_media_items(None, Some(&json!(""))).unwrap(), + vec![] + ); + assert_eq!( + normalize_media_items(Some(&json!([{ "id": "x" }])), None).unwrap_err(), + "Invalid media format" + ); + assert!(normalize_media_items(Some(&json!([null])), None).is_err()); + } + + #[test] + fn post_fields_validation() { + let fields = parse_post_fields( + &json!({ "content": "Bonjour", "scheduledFor": "2026-10-20T10:00:00Z" }), + ) + .unwrap(); + assert_eq!(fields.status, PostStatus::Scheduled); + assert_eq!( + fields.scheduled_for.unwrap().to_iso(), + "2026-10-20T10:00:00.000Z" + ); + + let fields = + parse_post_fields(&json!({ "content": "x", "status": "draft", "aiGenerated": "true" })) + .unwrap(); + assert_eq!(fields.status, PostStatus::Draft); + assert_eq!(fields.ai_generated.as_deref(), Some("true")); + + let error = parse_post_fields(&json!({})).err().unwrap(); + assert!(error.contains("\"path\": [\n \"content\"\n ]")); + assert!(error.contains("\"message\": \"Required\"")); + + let error = parse_post_fields(&json!({ "content": "x", "scheduledFor": "pas une date" })) + .err() + .unwrap(); + assert!(error.contains("Invalid date")); + } +} diff --git a/server/src/routes/core/scheduled.rs b/server/src/routes/core/scheduled.rs new file mode 100644 index 0000000..7033dc5 --- /dev/null +++ b/server/src/routes/core/scheduled.rs @@ -0,0 +1,253 @@ +//! Publications planifiées (calendrier) et agenda par magasin. + +use std::collections::{HashMap, HashSet}; + +use axum::extract::{Path, State}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::agenda::{ + agenda_item_from_scheduled_post, agenda_items_from_reel_job, AgendaItem, AgendaPage, +}; +use super::validate::{Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{NewScheduledPost, Patch, PostType, ScheduledPost}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, QueryMap}; +use crate::state::AppState; +use crate::time::Ts; + +pub fn router() -> Router { + Router::new() + .route("/api/scheduled-posts/agenda", get(agenda)) + .route( + "/api/scheduled-posts", + get(list_scheduled).post(create_scheduled), + ) + .route( + "/api/scheduled-posts/{id}", + patch(update_scheduled).delete(delete_scheduled), + ) +} + +/// Ce qui est déjà prévu par jour et par magasin, Reels en montage compris. +async fn agenda( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch publication agenda"; + let start = super::parse_date_param(query.get("startDate")); + let end = super::parse_date_param(query.get("endDate")); + + let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?; + if pages.is_empty() { + return Ok(Json(json!([]))); + } + let page_ids: Vec = pages.iter().map(|p| p.id.clone()).collect(); + let pages_by_id: HashMap = pages + .into_iter() + .map(|p| { + let page = AgendaPage { + id: p.id.clone(), + page_name: p.page_name, + platform: p.platform.as_str().to_string(), + avatar_url: p.avatar_url, + }; + (p.id, page) + }) + .collect(); + + let (scheduled, jobs) = tokio::try_join!( + state.db.get_scheduled_posts_by_pages(&page_ids, start, end), + state.db.get_rendering_reel_jobs(), + ) + .or_500(FAILED)?; + + let mut items: Vec = scheduled + .iter() + .map(agenda_item_from_scheduled_post) + .collect(); + // Un rendu qui vient de finir peut avoir déjà créé ses publications + let planned: HashSet = items.iter().map(|i| i.post_id.clone()).collect(); + for job in &jobs { + if job.post_id.as_ref().is_some_and(|id| planned.contains(id)) { + continue; + } + items.extend(agenda_items_from_reel_job(job, &pages_by_id, start, end)); + } + items.sort_by(|a, b| a.scheduled_at.cmp(&b.scheduled_at)); + Ok(Json(json!(items))) +} + +/// Admin : toutes les pages ; utilisateur : les pages qui lui sont attribuées. +async fn list_scheduled( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch scheduled posts"; + // Une date illisible est ignorée plutôt que transmise à SQL + let start = super::parse_date_param(query.get("startDate")); + let end = super::parse_date_param(query.get("endDate")); + + let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?; + let page_ids: Vec = pages.into_iter().map(|p| p.id).collect(); + let scheduled = state + .db + .get_scheduled_posts_by_pages(&page_ids, start, end) + .await + .or_500(FAILED)?; + Ok(Json(json!(scheduled))) +} + +/// Publication planifiée et droit de la modifier (admin ou auteur du post). +async fn owned_scheduled_post( + state: &AppState, + user: &AuthUser, + id: &str, + failed: &str, +) -> Result { + let scheduled = state + .db + .get_scheduled_post(id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Scheduled post not found"))?; + let post = state + .db + .get_post(&scheduled.post_id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if !user.is_admin() && post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + Ok(scheduled) +} + +async fn delete_scheduled( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete scheduled post"; + owned_scheduled_post(&state, &user, &id, FAILED).await?; + state.db.delete_scheduled_post(&id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +/// `new Date(valeur)` pour une chaîne ou un nombre de millisecondes. +fn js_date(value: &Value) -> Option { + match value { + Value::String(s) => Ts::parse(s), + Value::Number(n) => n.as_f64().and_then(|f| Ts::from_millis(f as i64)), + _ => None, + } +} + +/// Seules la date et la page peuvent changer ; la date est recopiée sur le post. +async fn update_scheduled( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update scheduled post"; + let scheduled = owned_scheduled_post(&state, &user, &id, FAILED).await?; + + let mut patch = Patch::new(); + if let Some(value) = body.get("scheduledAt").filter(|v| is_truthy(Some(v))) { + let date = js_date(value).ok_or_else(|| AppError::internal(FAILED))?; + patch = patch.set("scheduled_at", date); + state + .db + .update_post(&scheduled.post_id, Patch::new().set("scheduled_for", date)) + .await + .or_500(FAILED)?; + } + if let Some(page_id) = body.get("pageId").filter(|v| is_truthy(Some(v))) { + let Some(page_id) = page_id.as_str() else { + return Err(AppError::internal(FAILED)); + }; + patch = patch.set("page_id", page_id); + } + // Rien à modifier : l'ancienne requête de mise à jour échouait + if patch.is_empty() { + return Err(AppError::internal(FAILED)); + } + let updated = state + .db + .update_scheduled_post(&id, patch) + .await + .or_500(FAILED)?; + Ok(Json(json!(updated))) +} + +/// Création directe (schéma `insertScheduledPostSchema`). « both » crée deux +/// publications indépendantes (story + fil), renvoyées sous forme de tableau. +async fn create_scheduled( + State(state): State, + _user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to create scheduled post"; + + let mut issues = Issues::new(); + let post_id = issues + .string(&body, "postId", Presence::Required) + .into_option(); + let page_id = issues + .string(&body, "pageId", Presence::Required) + .into_option(); + let post_type = issues + .enumeration( + &body, + "postType", + Presence::Required, + &["feed", "story", "both", "reel"], + ) + .into_option() + .and_then(|t| PostType::parse(&t)); + // Plus tolérant que l'ancien `z.date()` (qui refusait toute date JSON) : + // une date ISO ou un nombre de millisecondes est accepté. + let scheduled_at = body.get("scheduledAt").and_then(js_date); + let publish_id = issues + .string(&body, "publishId", Presence::Nullable) + .into_option(); + let publish_status = issues + .string(&body, "publishStatus", Presence::Nullable) + .into_option(); + + let (Some(post_id), Some(page_id), Some(post_type), Some(scheduled_at)) = + (post_id, page_id, post_type, scheduled_at) + else { + tracing::error!("Error creating scheduled post: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + }; + if !issues.is_empty() { + tracing::error!("Error creating scheduled post: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let new = |post_type| NewScheduledPost { + publish_id: publish_id.clone(), + publish_status: publish_status.clone(), + ..NewScheduledPost::new(post_id.clone(), page_id.clone(), post_type, scheduled_at) + }; + if post_type == PostType::Both { + let (story, feed) = tokio::try_join!( + state.db.create_scheduled_post(new(PostType::Story)), + state.db.create_scheduled_post(new(PostType::Feed)), + ) + .or_500(FAILED)?; + return Ok(Json(json!([story, feed]))); + } + let created = state + .db + .create_scheduled_post(new(post_type)) + .await + .or_500(FAILED)?; + Ok(Json(json!(created))) +} diff --git a/server/src/routes/core/settings.rs b/server/src/routes/core/settings.rs new file mode 100644 index 0000000..d66efef --- /dev/null +++ b/server/src/routes/core/settings.rs @@ -0,0 +1,207 @@ +//! Paramètres globaux : clé de l'API externe, clé Gemini, service Qwen TTS. + +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use crate::auth::AdminUser; +use crate::db::{Patch, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::services::ffmpeg::{self, QwenTarget}; +use crate::services::reels::normalize_qwen_url; +use crate::state::AppState; + +const READ_FAILED: &str = "Erreur lors de la récupération de la configuration"; +const SAVE_FAILED: &str = "Erreur lors de la sauvegarde de la configuration"; +const DELETE_KEY_FAILED: &str = "Erreur lors de la suppression de la clé"; + +pub fn router() -> Router { + Router::new() + .route( + "/api/settings/external-api", + get(get_external_api) + .post(save_external_api) + .delete(delete_external_api), + ) + .route( + "/api/settings/gemini", + get(get_gemini).post(save_gemini).delete(delete_gemini), + ) + .route( + "/api/settings/qwen", + get(get_qwen).post(save_qwen).delete(delete_qwen), + ) + .route("/api/settings/qwen/test", post(test_qwen)) +} + +fn is_set(value: &Option) -> bool { + value.as_deref().is_some_and(|v| !v.is_empty()) +} + +/// Clé API obligatoire et non vide (`apiKey.trim() !== ""`). +fn required_api_key(body: &Value) -> Result { + body.trimmed("apiKey") + .ok_or_else(|| AppError::bad_request("La clé API est requise")) +} + +async fn get_external_api( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + Ok(Json( + json!({ "configured": config.is_some_and(|c| is_set(&c.external_api_key)) }), + )) +} + +async fn save_external_api( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let key = required_api_key(&body)?; + state + .db + .upsert_app_config(Patch::new().set("external_api_key", key)) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true, "configured": true }))) +} + +async fn delete_external_api( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + state + .db + .upsert_app_config(Patch::new().set_null("external_api_key")) + .await + .or_500(DELETE_KEY_FAILED)?; + Ok(Json(json!({ "success": true, "configured": false }))) +} + +async fn get_gemini(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + Ok(Json( + json!({ "configured": config.is_some_and(|c| is_set(&c.gemini_api_key)) }), + )) +} + +async fn save_gemini( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let key = required_api_key(&body)?; + state + .db + .upsert_app_config(Patch::new().set("gemini_api_key", key)) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true, "configured": true }))) +} + +async fn delete_gemini(State(state): State, _admin: AdminUser) -> ApiResult> { + state + .db + .upsert_app_config(Patch::new().set_null("gemini_api_key")) + .await + .or_500(DELETE_KEY_FAILED)?; + Ok(Json(json!({ "success": true, "configured": false }))) +} + +async fn get_qwen(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + let url = config + .as_ref() + .and_then(|c| c.qwen_tts_url.clone()) + .unwrap_or_default(); + let has_key = config.is_some_and(|c| is_set(&c.qwen_tts_api_key)); + Ok(Json(json!({ "url": url, "hasApiKey": has_key }))) +} + +async fn save_qwen( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let Some(url) = normalize_qwen_url(body.get("url")) else { + return Err(AppError::bad_request( + "Adresse invalide (ex. http://192.168.1.20:8001)", + )); + }; + let mut patch = Patch::new().set("qwen_tts_url", url); + // Clé laissée vide : on garde celle déjà enregistrée + if let Some(key) = body.trimmed("apiKey") { + patch = patch.set("qwen_tts_api_key", key); + } + state + .db + .upsert_app_config(patch) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +async fn delete_qwen(State(state): State, _admin: AdminUser) -> ApiResult> { + state + .db + .upsert_app_config( + Patch::new() + .set("qwen_tts_url", Val::Text(None)) + .set("qwen_tts_api_key", Val::Text(None)), + ) + .await + .or_500("Erreur lors de la suppression de la configuration")?; + Ok(Json(json!({ "success": true }))) +} + +/// Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen). +/// Répond toujours 200 : `{ ok: false, error }` en cas d'échec. +async fn test_qwen( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> Json { + match run_qwen_test(&state, &body).await { + Ok(value) => Json(value), + Err(error) => { + tracing::error!("Error testing Qwen config: {error:#}"); + Json(json!({ "ok": false, "error": error.to_string() })) + } + } +} + +async fn run_qwen_test(state: &AppState, body: &Value) -> anyhow::Result { + let config = state.db.get_app_config().await?; + let url = normalize_qwen_url(body.get("url")) + .or_else(|| config.as_ref().and_then(|c| c.qwen_tts_url.clone())) + .filter(|u| !u.is_empty()); + let api_key = body + .trimmed("apiKey") + .or_else(|| config.as_ref().and_then(|c| c.qwen_tts_api_key.clone())) + .filter(|k| !k.is_empty()); + + let catalog = ffmpeg::list_voices(state, url.map(|url| QwenTarget { url, api_key })).await?; + let status = catalog + .qwen_status + .as_ref() + .filter(|s| crate::extract::is_truthy(Some(s))); + let Some(status) = status.filter(|_| catalog.qwen_available) else { + return Ok( + json!({ "ok": false, "error": "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }), + ); + }; + + let mut result = serde_json::Map::new(); + result.insert("ok".into(), Value::Bool(true)); + if let Value::Object(fields) = status { + for (key, value) in fields { + result.insert(key.clone(), value.clone()); + } + } + result.insert("voices".into(), json!(catalog.qwen.len())); + Ok(Value::Object(result)) +} diff --git a/server/src/routes/core/sql.rs b/server/src/routes/core/sql.rs new file mode 100644 index 0000000..4bb4155 --- /dev/null +++ b/server/src/routes/core/sql.rs @@ -0,0 +1,645 @@ +//! Console SQL (administrateurs). +//! +//! L'ancienne route passait la requête telle quelle à `db.execute` (pilote +//! `pg`, protocole simple) et renvoyait l'objet `QueryResult` de `pg` : +//! `{ command, rowCount, oid, rows, fields, … }`, ou un tableau de ces objets +//! quand la requête contenait plusieurs instructions. Les valeurs sont +//! converties comme le faisait `pg-types`, avec les exceptions de Drizzle +//! (dates, horodatages et intervalles laissés en texte brut). + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures::TryStreamExt; +use serde_json::{json, Map, Number, Value}; +use sqlx::postgres::{PgRow, PgValueRef}; +use sqlx::{Column, Either, Row, TypeInfo, ValueRef}; + +use super::validate::{Issues, Presence}; +use crate::auth::AdminUser; +use crate::error::{ApiResult, AppError}; +use crate::extract::Body; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/sql/execute", post(execute)) + .route("/api/sql/tables", get(tables)) +} + +async fn execute( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let mut issues = Issues::new(); + let query = issues.string_min( + &body, + "query", + Presence::Required, + 1, + "La requête SQL ne peut pas être vide", + ); + let Some(query) = query.into_option().filter(|_| issues.is_empty()) else { + return Err(AppError::json( + StatusCode::BAD_REQUEST, + json!({ "success": false, "error": issues.first_message().unwrap_or_else(|| "Données invalides".into()) }), + )); + }; + + match run_query(&state, &query).await { + Ok(result) => Ok(Json(json!({ "success": true, "result": result }))), + Err(error) => { + tracing::error!("Error executing SQL: {error}"); + let message = super::pg_message(&error); + let message = if message.is_empty() { + "Erreur lors de l'exécution de la requête SQL".to_string() + } else { + message + }; + Err(AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "success": false, "error": message }), + )) + } + } +} + +async fn tables(State(state): State, _admin: AdminUser) -> ApiResult> { + let names: Result, sqlx::Error> = sqlx::query_scalar( + "SELECT tablename::text FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename", + ) + .fetch_all(&state.db.pool) + .await; + match names { + Ok(names) => Ok(Json(json!({ + "tables": names.into_iter().map(|t| json!({ "tablename": t })).collect::>(), + }))), + Err(error) => { + tracing::error!("Error fetching tables: {error}"); + Err(AppError::internal(super::pg_message(&error))) + } + } +} + +/// Résultat d'une instruction en cours de lecture. +#[derive(Default)] +struct StatementResult { + rows: Vec, + fields: Vec, +} + +/// Exécute la requête par le protocole simple (plusieurs instructions +/// possibles, comme avec `pg`) et assemble les résultats au format `pg`. +async fn run_query(state: &AppState, query: &str) -> Result { + let commands = statement_commands(query); + let mut results: Vec = Vec::new(); + let mut current = StatementResult::default(); + + let mut stream = sqlx::raw_sql(query).fetch_many(&state.db.pool); + while let Some(item) = stream.try_next().await? { + match item { + Either::Right(row) => { + if current.fields.is_empty() { + current.fields = row_fields(&row); + } + current.rows.push(row_to_json(&row)); + } + Either::Left(done) => { + let command = commands.get(results.len()).cloned().unwrap_or_default(); + let finished = std::mem::take(&mut current); + results.push(query_result(&command, done.rows_affected(), finished)); + } + } + } + + Ok(match results.len() { + // Requête vide (commentaire seul…) : objet résultat vierge + 0 => json!({ + "command": null, "rowCount": null, "oid": null, "rows": [], "fields": [], + "_types": {}, "RowCtor": null, "rowAsArray": false, "_prebuiltEmptyResultObject": null, + }), + 1 => results.pop().unwrap_or(Value::Null), + _ => Value::Array(results), + }) +} + +/// Objet `Result` de `pg` pour une instruction terminée. +fn query_result(command: &str, rows_affected: u64, result: StatementResult) -> Value { + // `pg` lit l'étiquette « COMMANDE [OID] LIGNES » envoyée par PostgreSQL + let (row_count, oid) = match command { + "INSERT" => (json!(rows_affected), json!(0)), + "SELECT" | "UPDATE" | "DELETE" | "MERGE" | "MOVE" | "FETCH" | "COPY" => { + (json!(rows_affected), Value::Null) + } + _ => (Value::Null, Value::Null), + }; + let empty_row: Map = result + .fields + .iter() + .filter_map(|f| f.get("name").and_then(Value::as_str)) + .map(|name| (name.to_string(), Value::Null)) + .collect(); + let has_fields = !result.fields.is_empty(); + let field_count = result.fields.len(); + // Même ordre de clés que l'objet `Result` de `pg` + let mut object = Map::new(); + object.insert( + "command".into(), + if command.is_empty() { + Value::Null + } else { + json!(command) + }, + ); + object.insert("rowCount".into(), row_count); + object.insert("oid".into(), oid); + object.insert("rows".into(), Value::Array(result.rows)); + object.insert("fields".into(), Value::Array(result.fields)); + if has_fields { + // Les analyseurs de `pg` sont des fonctions : `null` une fois sérialisés + object.insert( + "_parsers".into(), + Value::Array(vec![Value::Null; field_count]), + ); + } + object.insert("_types".into(), json!({})); + object.insert("RowCtor".into(), Value::Null); + object.insert("rowAsArray".into(), Value::Bool(false)); + object.insert( + "_prebuiltEmptyResultObject".into(), + if has_fields { + Value::Object(empty_row) + } else { + Value::Null + }, + ); + Value::Object(object) +} + +/// Description des colonnes (`fields` de `pg`). +fn row_fields(row: &PgRow) -> Vec { + row.columns() + .iter() + .map(|column| { + let type_info = column.type_info(); + json!({ + "name": column.name(), + "tableID": column.relation_id().map(|o| o.0).unwrap_or(0), + "columnID": column.relation_attribute_no().unwrap_or(0), + "dataTypeID": type_info.oid().map(|o| o.0).unwrap_or(0), + "format": "text", + }) + }) + .collect() +} + +fn row_to_json(row: &PgRow) -> Value { + let mut object = Map::new(); + for (index, column) in row.columns().iter().enumerate() { + let oid = column.type_info().oid().map(|o| o.0).unwrap_or(0); + let value = match row.try_get_raw(index) { + Ok(raw) => raw_to_json(oid, column.type_info().name(), raw), + Err(_) => Value::Null, + }; + // Colonnes homonymes : la dernière l'emporte, à la place de la première + object.insert(column.name().to_string(), value); + } + Value::Object(object) +} + +fn raw_to_json(oid: u32, type_name: &str, raw: PgValueRef<'_>) -> Value { + if raw.is_null() { + return Value::Null; + } + match raw.as_str() { + Ok(text) => text_to_json(oid, text), + // Valeur binaire (jamais en protocole simple) : nom du type en repli + Err(_) => Value::String(format!("<{type_name}>")), + } +} + +/// Conversion d'une valeur texte PostgreSQL, comme les analyseurs de `pg-types`. +pub(crate) fn text_to_json(oid: u32, text: &str) -> Value { + match oid { + // int2, int4, oid + 21 | 23 | 26 => parse_int(text), + // float4, float8 + 700 | 701 => parse_float(text), + 16 => Value::Bool(matches!( + text, + "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1" + )), + 114 | 3802 => serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into())), + 17 => parse_bytea(text), + 600 => parse_point(text), + // Tableaux + 1000 => parse_array(text, &|e| { + Value::Bool(matches!( + e, + "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1" + )) + }), + 1005 | 1007 | 1028 => parse_array(text, &parse_int), + 1016 => parse_array(text, &|e| Value::String(e.trim().into())), + 1021 | 1022 | 1231 => parse_array(text, &parse_float), + 199 | 3807 => parse_array(text, &|e| { + serde_json::from_str(e).unwrap_or_else(|_| Value::String(e.into())) + }), + 1115 | 1182 | 1185 => parse_array(text, &parse_date), + 1001 => parse_array(text, &parse_bytea), + 651 | 1014 | 1015 | 1008 | 1009 | 1040 | 1041 | 3907 | 2951 | 791 | 1183 | 1270 => { + parse_array(text, &|e| Value::String(e.into())) + } + // int8 et numeric restent du texte ; date, timestamp(tz), interval + // aussi (Drizzle désactive leur conversion) ; uuid, enums… : texte + _ => Value::String(text.into()), + } +} + +/// `parseInt(value, 10)` : `null` si illisible (NaN sérialisé par JSON). +fn parse_int(text: &str) -> Value { + let trimmed = text.trim(); + let (sign, digits) = match trimmed.strip_prefix('-') { + Some(rest) => (-1i64, rest), + None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + let end = digits.bytes().take_while(u8::is_ascii_digit).count(); + match digits[..end].parse::() { + Ok(n) => json!(sign * n), + Err(_) => Value::Null, + } +} + +/// `parseFloat` : `NaN` et `Infinity` deviennent `null` en JSON. +fn parse_float(text: &str) -> Value { + match text.trim().parse::() { + Ok(f) if f.is_finite() => { + if f.fract() == 0.0 && f.abs() < 9e15 { + json!(f as i64) + } else { + Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } + } + _ => Value::Null, + } +} + +/// Élément de tableau date/horodatage : objet `Date` JavaScript, donc ISO 8601. +fn parse_date(text: &str) -> Value { + let mut value = text.to_string(); + // Décalage « +02 » sans minutes, refusé par chrono + if let Some(pos) = value.rfind(['+', '-']) { + if pos > 10 && value.len() - pos == 3 { + value.push_str(":00"); + } + } + match crate::time::parse_js_date(&value) { + Some(ts) => Value::String(ts.to_iso()), + None => Value::Null, + } +} + +/// `bytea` (format hexadécimal) : objet `Buffer` sérialisé. +fn parse_bytea(text: &str) -> Value { + match text + .strip_prefix("\\x") + .and_then(|hex_text| hex::decode(hex_text).ok()) + { + Some(bytes) => json!({ "type": "Buffer", "data": bytes }), + None => Value::String(text.into()), + } +} + +fn parse_point(text: &str) -> Value { + let inner = text.trim().trim_start_matches('(').trim_end_matches(')'); + let mut parts = inner.split(',').map(|p| p.trim().parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(x), Some(y)) => json!({ "x": x, "y": y }), + _ => Value::String(text.into()), + } +} + +/// Tableau PostgreSQL (`{1,2,NULL}`, `{"a b","c\"d"}`, imbriqué) en JSON. +fn parse_array(text: &str, element: &dyn Fn(&str) -> Value) -> Value { + let text = text.trim(); + // Bornes explicites (« [0:1]={…} ») : on les ignore + let text = match (text.starts_with('['), text.find('=')) { + (true, Some(pos)) => &text[pos + 1..], + _ => text, + }; + let chars: Vec = text.chars().collect(); + let mut pos = 0; + match parse_array_level(&chars, &mut pos, element) { + Some(value) => value, + None => Value::String(text.into()), + } +} + +fn parse_array_level( + chars: &[char], + pos: &mut usize, + element: &dyn Fn(&str) -> Value, +) -> Option { + if chars.get(*pos) != Some(&'{') { + return None; + } + *pos += 1; + let mut items = Vec::new(); + loop { + match chars.get(*pos)? { + '}' => { + *pos += 1; + return Some(Value::Array(items)); + } + ',' => { + *pos += 1; + } + '{' => items.push(parse_array_level(chars, pos, element)?), + '"' => { + *pos += 1; + let mut buf = String::new(); + loop { + match chars.get(*pos)? { + '\\' => { + buf.push(*chars.get(*pos + 1)?); + *pos += 2; + } + '"' => { + *pos += 1; + break; + } + c => { + buf.push(*c); + *pos += 1; + } + } + } + items.push(element(&buf)); + } + _ => { + let start = *pos; + while !matches!(chars.get(*pos), Some(',') | Some('}') | None) { + *pos += 1; + } + let raw: String = chars[start..*pos].iter().collect(); + let raw = raw.trim(); + items.push(if raw.eq_ignore_ascii_case("NULL") { + Value::Null + } else { + element(raw) + }); + } + } + } +} + +/// Commande de chaque instruction (« SELECT », « INSERT »…), dans l'ordre : +/// l'étiquette envoyée par PostgreSQL n'est pas exposée par sqlx. +fn statement_commands(query: &str) -> Vec { + split_statements(query) + .iter() + .filter_map(|statement| { + let word: String = statement + .trim_start() + .chars() + .take_while(|c| c.is_ascii_alphabetic()) + .collect::() + .to_ascii_uppercase(); + if word.is_empty() { + return None; + } + Some(match word.as_str() { + // VALUES et TABLE renvoient l'étiquette SELECT + "VALUES" | "TABLE" => "SELECT".to_string(), + "WITH" => with_command(statement), + "START" => "START".to_string(), + _ => word, + }) + }) + .collect() +} + +/// `WITH … SELECT/INSERT/UPDATE/DELETE` : premier mot-clé principal hors parenthèses. +fn with_command(statement: &str) -> String { + let upper = statement.to_ascii_uppercase(); + let mut depth = 0i32; + let mut word = String::new(); + for c in upper.chars().chain(std::iter::once(' ')) { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + if c.is_ascii_alphabetic() { + word.push(c); + } else { + if depth == 0 + && matches!( + word.as_str(), + "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "MERGE" + ) + { + return word; + } + word.clear(); + } + } + "SELECT".to_string() +} + +/// Découpe une requête en instructions sur les « ; » hors chaînes, +/// identifiants entre guillemets, chaînes dollar et commentaires. +/// Les commentaires sont retirés du texte des instructions. +fn split_statements(query: &str) -> Vec { + let chars: Vec = query.chars().collect(); + let mut statements = Vec::new(); + let mut current = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + match c { + '\'' | '"' => { + let quote = c; + current.push(c); + i += 1; + while i < chars.len() { + current.push(chars[i]); + if chars[i] == quote { + if chars.get(i + 1) == Some("e) { + current.push(quote); + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + '-' if chars.get(i + 1) == Some(&'-') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + current.push(' '); + } + '/' if chars.get(i + 1) == Some(&'*') => { + let mut depth = 0; + while i < chars.len() { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + if depth == 0 { + break; + } + } else { + i += 1; + } + } + current.push(' '); + } + '$' => { + // Étiquette de chaîne dollar : $$ ou $tag$ + let mut j = i + 1; + while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') { + j += 1; + } + let is_tag = chars.get(j) == Some(&'$') + && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) + && !current + .chars() + .last() + .is_some_and(|p| p.is_alphanumeric() || p == '_'); + if is_tag { + let tag: Vec = chars[i..=j].to_vec(); + current.extend(&tag); + i = j + 1; + while i < chars.len() { + if chars[i..].starts_with(&tag) { + current.extend(&tag); + i += tag.len(); + break; + } + current.push(chars[i]); + i += 1; + } + } else { + current.push(c); + i += 1; + } + } + ';' => { + statements.push(std::mem::take(&mut current)); + i += 1; + } + _ => { + current.push(c); + i += 1; + } + } + } + statements.push(current); + statements + .into_iter() + .filter(|s| !s.trim().is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_conversions_follow_pg_types() { + assert_eq!(text_to_json(23, "42"), json!(42)); + assert_eq!( + text_to_json(20, "9007199254740993"), + json!("9007199254740993") + ); + assert_eq!(text_to_json(1700, "12.50"), json!("12.50")); + assert_eq!(text_to_json(701, "1.5"), json!(1.5)); + assert_eq!(text_to_json(701, "3"), json!(3)); + assert_eq!(text_to_json(701, "NaN"), Value::Null); + assert_eq!(text_to_json(16, "t"), json!(true)); + assert_eq!(text_to_json(16, "f"), json!(false)); + assert_eq!( + text_to_json(3802, r#"{"a": [1, 2]}"#), + json!({ "a": [1, 2] }) + ); + // Drizzle laisse les dates en texte brut + assert_eq!( + text_to_json(1114, "2024-05-01 08:30:00.123"), + json!("2024-05-01 08:30:00.123") + ); + assert_eq!(text_to_json(2950, "a0ee-…"), json!("a0ee-…")); + assert_eq!( + text_to_json(17, "\\x0102"), + json!({ "type": "Buffer", "data": [1, 2] }) + ); + assert_eq!(text_to_json(600, "(1.5,2)"), json!({ "x": 1.5, "y": 2.0 })); + } + + #[test] + fn array_conversions() { + assert_eq!(text_to_json(1007, "{1,2,NULL}"), json!([1, 2, null])); + assert_eq!( + text_to_json(1009, r#"{"a b","c\"d",e}"#), + json!(["a b", "c\"d", "e"]) + ); + assert_eq!(text_to_json(1007, "{{1,2},{3,4}}"), json!([[1, 2], [3, 4]])); + assert_eq!(text_to_json(1016, "{10,20}"), json!(["10", "20"])); + assert_eq!(text_to_json(1009, "{}"), json!([])); + assert_eq!( + text_to_json(1185, r#"{"2024-05-01 10:30:00+02"}"#), + json!(["2024-05-01T08:30:00.000Z"]) + ); + assert_eq!( + text_to_json(1115, r#"{"2024-05-01 08:30:00"}"#), + json!(["2024-05-01T08:30:00.000Z"]) + ); + } + + #[test] + fn statement_splitting() { + let commands = statement_commands( + "select 'a;b'; -- commentaire ; ici\nINSERT INTO t VALUES ($$x;y$$); /* ; */ ; with x as (select 1) delete from t; values (1)", + ); + assert_eq!(commands, vec!["SELECT", "INSERT", "DELETE", "SELECT"]); + assert_eq!(statement_commands(" "), Vec::::new()); + assert_eq!( + statement_commands("create table \"a;b\" (id int)"), + vec!["CREATE"] + ); + } + + #[test] + fn result_shape() { + let result = query_result( + "SELECT", + 1, + StatementResult { + rows: vec![json!({ "a": 1 })], + fields: vec![json!({ "name": "a" })], + }, + ); + let keys: Vec<&String> = result.as_object().unwrap().keys().collect(); + assert_eq!( + keys[..6], + ["command", "rowCount", "oid", "rows", "fields", "_parsers"] + ); + assert_eq!(result["rowCount"], 1); + assert_eq!(result["_prebuiltEmptyResultObject"], json!({ "a": null })); + let create = query_result("CREATE", 0, StatementResult::default()); + assert_eq!(create["rowCount"], Value::Null); + assert_eq!( + query_result("INSERT", 2, StatementResult::default())["oid"], + 0 + ); + } +} diff --git a/server/src/routes/core/stats.rs b/server/src/routes/core/stats.rs new file mode 100644 index 0000000..d228cb9 --- /dev/null +++ b/server/src/routes/core/stats.rs @@ -0,0 +1,122 @@ +//! Compteurs du tableau de bord. + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime}; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::error::{ApiResult, OrApiError}; +use crate::state::AppState; +use crate::time::Ts; + +pub fn router() -> Router { + Router::new().route("/api/stats", get(stats)) +} + +async fn stats(State(state): State, user: AuthUser) -> ApiResult> { + let now = Ts::now(); + let (yesterday_start, yesterday_end, last_month) = periods(now.0); + let stats = state + .db + .get_dashboard_stats( + &user.id, + Ts(yesterday_start), + Ts(yesterday_end), + Ts(last_month), + ) + .await + .or_500("Failed to fetch stats")?; + + let ai_change = change_percent(stats.ai_texts_generated, stats.ai_texts_yesterday); + let scheduled_change = change_percent(stats.scheduled_posts, stats.scheduled_posts_last_month); + + Ok(Json(json!({ + "scheduledPosts": stats.scheduled_posts, + "scheduledPostsChange": format_change(scheduled_change), + "scheduledPostsTrending": if scheduled_change >= 0 { "up" } else { "down" }, + "connectedPages": stats.connected_pages, + "aiTextsGenerated": stats.ai_texts_generated, + "aiTextsChange": format_change(ai_change), + "aiTextsTrending": if ai_change >= 0 { "up" } else { "down" }, + "mediaStored": stats.media_stored, + }))) +} + +/// Début d'hier, début d'aujourd'hui (minuit UTC, l'heure du conteneur) et +/// même instant le mois dernier (`setMonth(getMonth() - 1)`). +fn periods(now: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime, NaiveDateTime) { + let yesterday = (now - Duration::days(1)) + .date() + .and_hms_opt(0, 0, 0) + .unwrap_or(now); + let today = yesterday + Duration::days(1); + (yesterday, today, previous_month(now)) +} + +/// `Date.setMonth(mois - 1)` : un jour qui n'existe pas déborde sur le mois +/// suivant (31 mars → 3 mars, ou 2 mars une année bissextile). +fn previous_month(now: NaiveDateTime) -> NaiveDateTime { + let (year, month) = if now.month() == 1 { + (now.year() - 1, 12) + } else { + (now.year(), now.month() - 1) + }; + let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(now.date()); + (first + Duration::days(i64::from(now.day()) - 1)).and_time(now.time()) +} + +/// Variation en pourcentage, arrondie comme `Math.round`. +fn change_percent(current: i64, previous: i64) -> i64 { + if previous > 0 { + let ratio = (current - previous) as f64 / previous as f64 * 100.0; + (ratio + 0.5).floor() as i64 + } else if current > 0 { + 100 + } else { + 0 + } +} + +fn format_change(change: i64) -> String { + if change > 0 { + format!("+{change}%") + } else { + format!("{change}%") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(s: &str) -> NaiveDateTime { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap() + } + + #[test] + fn percentages() { + assert_eq!(format_change(change_percent(5, 0)), "+100%"); + assert_eq!(format_change(change_percent(0, 0)), "0%"); + assert_eq!(format_change(change_percent(3, 2)), "+50%"); + assert_eq!(format_change(change_percent(1, 2)), "-50%"); + assert_eq!(format_change(change_percent(2, 2)), "0%"); + // Math.round(-2.5) = -2, Math.round(2.5) = 3 + assert_eq!(change_percent(195, 200), -2); + assert_eq!(change_percent(41, 40), 3); + // -0,4 % arrondi donne 0 (et non « -0 ») + assert_eq!(format_change(change_percent(996, 1000)), "0%"); + } + + #[test] + fn utc_periods() { + let (start, end, month) = periods(at("2026-03-31 15:20:00")); + assert_eq!(start, at("2026-03-30 00:00:00")); + assert_eq!(end, at("2026-03-31 00:00:00")); + assert_eq!(month, at("2026-03-03 15:20:00")); + let (start, _, month) = periods(at("2026-01-01 00:10:00")); + assert_eq!(start, at("2025-12-31 00:00:00")); + assert_eq!(month, at("2025-12-01 00:10:00")); + } +} diff --git a/server/src/routes/core/users.rs b/server/src/routes/core/users.rs new file mode 100644 index 0000000..f2536fd --- /dev/null +++ b/server/src/routes/core/users.rs @@ -0,0 +1,254 @@ +//! Utilisateurs et permissions sur les pages (administrateurs). + +use axum::extract::{Path, State}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Issues, Presence}; +use crate::auth::{hash_password, user_json, AdminUser}; +use crate::db::{Patch, UserRole}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/users", get(list_users).post(create_user)) + .route("/api/users/{id}", patch(update_user).delete(delete_user)) + .route( + "/api/users/{id}/page-permissions", + get(get_permissions).post(set_permissions), + ) + .route("/api/admin/migrate-permissions", post(migrate_permissions)) +} + +async fn list_users(State(state): State, _admin: AdminUser) -> ApiResult> { + let users = state + .db + .get_all_users() + .await + .or_500("Erreur lors de la récupération des utilisateurs")?; + Ok(Json(Value::Array(users.iter().map(user_json).collect()))) +} + +async fn create_user( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la création de l'utilisateur"; + + let mut issues = Issues::new(); + let username = issues.string_min( + &body, + "username", + Presence::Required, + 3, + "Le nom d'utilisateur doit contenir au moins 3 caractères", + ); + let password = issues.string_min( + &body, + "password", + Presence::Required, + 4, + "Le mot de passe doit contenir au moins 4 caractères", + ); + let role = issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]); + if !issues.is_empty() { + tracing::error!("Error creating user: {}", issues.zod_message()); + return Err(AppError::bad_request( + issues + .first_message() + .unwrap_or_else(|| "Données invalides".into()), + )); + } + let (Some(username), Some(password)) = (username.into_option(), password.into_option()) else { + return Err(AppError::bad_request("Données invalides")); + }; + + if state + .db + .get_user_by_username(&username) + .await + .or_500(FAILED)? + .is_some() + { + return Err(AppError::conflict("Ce nom d'utilisateur existe déjà")); + } + + let hash = hash_password(&password).await.or_500(FAILED)?; + let role = role + .into_option() + .and_then(|r| UserRole::parse(&r)) + .unwrap_or(UserRole::User); + let user = state + .db + .create_user(&username, &hash, role) + .await + .or_500(FAILED)?; + Ok(Json(user_json(&user))) +} + +async fn update_user( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la modification de l'utilisateur"; + + let existing = state + .db + .get_user(&user_id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Utilisateur non trouvé"))?; + + let mut patch = Patch::new(); + + if let Some(username) = body.str_field("username").filter(|u| !u.is_empty()) { + if username != existing.username { + if let Some(other) = state + .db + .get_user_by_username(username) + .await + .or_500(FAILED)? + { + if other.id != user_id { + return Err(AppError::conflict("Ce nom d'utilisateur est déjà utilisé")); + } + } + patch = patch.set("username", username); + } + } else if body.truthy("username") { + // Valeur non textuelle : l'ancienne route échouait à l'écriture + return Err(AppError::internal(FAILED)); + } + + if body.truthy("password") { + let Some(password) = body.str_field("password") else { + return Err(AppError::internal(FAILED)); + }; + let hash = hash_password(password).await.or_500(FAILED)?; + patch = patch.set("password", hash); + } + + if let Some(role) = body.str_field("role").and_then(UserRole::parse) { + patch = patch.set("role", role); + } + + if patch.is_empty() { + return Err(AppError::bad_request("Aucune modification fournie")); + } + + let updated = state + .db + .update_user(&user_id, patch) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::internal(FAILED))?; + Ok(Json(user_json(&updated))) +} + +async fn delete_user( + State(state): State, + admin: AdminUser, + Path(user_id): Path, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la suppression de l'utilisateur"; + + if user_id == admin.id { + return Err(AppError::bad_request( + "Vous ne pouvez pas supprimer votre propre compte", + )); + } + if state.db.get_user(&user_id).await.or_500(FAILED)?.is_none() { + return Err(AppError::not_found("Utilisateur non trouvé")); + } + state.db.delete_user(&user_id).await.or_500(FAILED)?; + Ok(Json( + json!({ "success": true, "message": "Utilisateur supprimé avec succès" }), + )) +} + +async fn get_permissions( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, +) -> ApiResult> { + let permissions = state + .db + .get_user_page_permissions(&user_id) + .await + .or_500("Erreur lors de la récupération des permissions")?; + Ok(Json(json!(permissions))) +} + +async fn set_permissions( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la mise à jour des permissions"; + + let Some(page_ids) = body.get("pageIds").and_then(Value::as_array) else { + return Err(AppError::bad_request("pageIds doit être un tableau")); + }; + // Identifiants non textuels : l'insertion échouait côté base + let page_ids: Vec<&str> = page_ids + .iter() + .map(Value::as_str) + .collect::>() + .ok_or_else(|| AppError::internal(FAILED))?; + + state + .db + .delete_all_user_page_permissions(&user_id) + .await + .or_500(FAILED)?; + let mut permissions = Vec::with_capacity(page_ids.len()); + for page_id in page_ids { + permissions.push( + state + .db + .create_page_permission(&user_id, page_id) + .await + .or_500(FAILED)?, + ); + } + Ok(Json(json!(permissions))) +} + +/// Donne à chaque utilisateur l'accès aux pages qu'il a lui-même connectées. +async fn migrate_permissions( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la migration des permissions"; + + let mut migrated = 0; + for user in state.db.get_all_users().await.or_500(FAILED)? { + for page in state.db.get_social_pages(&user.id).await.or_500(FAILED)? { + let existing = state + .db + .get_user_page_permissions(&user.id) + .await + .or_500(FAILED)?; + if !existing.iter().any(|p| p.page_id == page.id) { + state + .db + .create_page_permission(&user.id, &page.id) + .await + .or_500(FAILED)?; + migrated += 1; + } + } + } + Ok(Json(json!({ + "success": true, + "message": format!("{migrated} permissions migrées avec succès"), + "migratedCount": migrated, + }))) +} diff --git a/server/src/routes/core/validate.rs b/server/src/routes/core/validate.rs new file mode 100644 index 0000000..76fb9b4 --- /dev/null +++ b/server/src/routes/core/validate.rs @@ -0,0 +1,304 @@ +//! Validation des corps de requête à la manière des schémas zod +//! (`createInsertSchema` de drizzle-zod) : mêmes champs requis, mêmes types et +//! mêmes messages d'erreur (« Required », « Expected string, received number »…). + +use serde_json::{json, Value}; + +use crate::time::Ts; + +/// Champ absent (`undefined`), `null` ou présent. +#[derive(Debug, Clone, PartialEq)] +pub enum Field { + Absent, + Null, + Set(T), +} + +impl Field { + pub fn into_option(self) -> Option { + match self { + Field::Set(v) => Some(v), + _ => None, + } + } + + pub fn is_absent(&self) -> bool { + matches!(self, Field::Absent) + } +} + +/// Le champ peut-il manquer, être `null` ? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presence { + Required, + Optional, + Nullable, +} + +/// Liste des problèmes relevés, dans l'ordre du schéma. +#[derive(Debug, Default)] +pub struct Issues(Vec); + +/// Type reçu, au sens de zod (`getParsedType`). +fn received(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::String(_)) => "string", + Some(Value::Number(_)) => "number", + Some(Value::Bool(_)) => "boolean", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +impl Issues { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Message du premier problème (`error.errors[0].message`). + pub fn first_message(&self) -> Option { + self.0 + .first() + .and_then(|i| i.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + } + + /// `ZodError.message` : la liste des problèmes en JSON indenté. + pub fn zod_message(&self) -> String { + serde_json::to_string_pretty(&self.0).unwrap_or_default() + } + + pub fn push_invalid_type(&mut self, key: &str, expected: &str, value: Option<&Value>) { + let received = received(value); + let message = if value.is_none() { + "Required".to_string() + } else { + format!("Expected {expected}, received {received}") + }; + self.0.push(json!({ + "code": "invalid_type", + "expected": expected, + "received": received, + "path": [key], + "message": message, + })); + } + + /// Gère absence et `null` ; `Some(valeur)` s'il faut encore la vérifier. + fn presence<'a>( + &mut self, + body: &'a Value, + key: &str, + expected: &str, + presence: Presence, + ) -> Result<&'a Value, Option>> { + match body.get(key) { + None if presence == Presence::Required => { + self.push_invalid_type(key, expected, None); + Err(None) + } + None => Err(Some(Field::Absent)), + Some(Value::Null) if presence == Presence::Nullable => Err(Some(Field::Null)), + Some(value) => Ok(value), + } + } + + fn convert(outcome: Option>) -> Field { + match outcome { + Some(Field::Null) => Field::Null, + _ => Field::Absent, + } + } + + pub fn string(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "string", presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::String(s)) => Field::Set(s.clone()), + Ok(other) => { + self.push_invalid_type(key, "string", Some(other)); + Field::Absent + } + } + } + + /// Chaîne d'au moins `min` caractères (longueur JavaScript). + pub fn string_min( + &mut self, + body: &Value, + key: &str, + presence: Presence, + min: usize, + message: &str, + ) -> Field { + let field = self.string(body, key, presence); + if let Field::Set(s) = &field { + if s.encode_utf16().count() < min { + self.0.push(json!({ + "code": "too_small", + "minimum": min, + "type": "string", + "inclusive": true, + "exact": false, + "message": message, + "path": [key], + })); + } + } + field + } + + pub fn enumeration( + &mut self, + body: &Value, + key: &str, + presence: Presence, + options: &[&str], + ) -> Field { + let expected = options + .iter() + .map(|o| format!("'{o}'")) + .collect::>() + .join(" | "); + match self.presence(body, key, &expected, presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::String(s)) if options.contains(&s.as_str()) => Field::Set(s.clone()), + Ok(Value::String(s)) => { + self.0.push(json!({ + "received": s, + "code": "invalid_enum_value", + "options": options, + "path": [key], + "message": format!("Invalid enum value. Expected {expected}, received '{s}'"), + })); + Field::Absent + } + Ok(other) => { + self.push_invalid_type(key, &expected, Some(other)); + Field::Absent + } + } + } + + /// Entier 32 bits (`integer` PostgreSQL). + pub fn int(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "number", presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::Number(n)) => { + let f = n.as_f64().unwrap_or(f64::NAN); + if f.fract() != 0.0 { + self.0.push(json!({ + "code": "invalid_type", + "expected": "integer", + "received": "float", + "path": [key], + "message": "Expected integer, received float", + })); + return Field::Absent; + } + if f < i32::MIN as f64 { + self.0.push(json!({ + "code": "too_small", "minimum": i32::MIN, "type": "number", "inclusive": true, "exact": false, + "path": [key], "message": format!("Number must be greater than or equal to {}", i32::MIN), + })); + return Field::Absent; + } + if f > i32::MAX as f64 { + self.0.push(json!({ + "code": "too_big", "maximum": i32::MAX, "type": "number", "inclusive": true, "exact": false, + "path": [key], "message": format!("Number must be less than or equal to {}", i32::MAX), + })); + return Field::Absent; + } + Field::Set(f as i32) + } + Ok(other) => { + self.push_invalid_type(key, "number", Some(other)); + Field::Absent + } + } + } + + /// Champ `z.date()` : un corps JSON ne contient jamais d'objet Date, donc + /// toute valeur présente est refusée (seuls absence et `null` passent). + pub fn date(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "date", presence) { + Err(outcome) => Self::convert(outcome), + Ok(other) => { + self.push_invalid_type(key, "date", Some(other)); + Field::Absent + } + } + } + + /// Date déjà convertie par la route (`new Date(chaîne)`) : invalide si illisible. + pub fn converted_date(&mut self, key: &str, value: Option) -> Option { + if value.is_none() { + self.0 + .push(json!({ "code": "invalid_date", "path": [key], "message": "Invalid date" })); + } + value + } + + /// Champ JSON libre (`jsonb`) : toute valeur JSON est acceptée. + pub fn json(&mut self, body: &Value, key: &str) -> Field { + match body.get(key) { + None => Field::Absent, + Some(Value::Null) => Field::Null, + Some(value) => Field::Set(value.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_and_type_messages() { + let body = json!({ "password": 12 }); + let mut issues = Issues::new(); + issues.string(&body, "username", Presence::Required); + issues.string(&body, "password", Presence::Required); + assert_eq!(issues.first_message().as_deref(), Some("Required")); + assert!(issues + .zod_message() + .contains("\"message\": \"Expected string, received number\"")); + } + + #[test] + fn min_length_and_enum() { + let body = json!({ "username": "ab", "role": "boss" }); + let mut issues = Issues::new(); + issues.string_min(&body, "username", Presence::Required, 3, "trop court"); + issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]); + assert_eq!(issues.first_message().as_deref(), Some("trop court")); + assert!(issues + .zod_message() + .contains("Invalid enum value. Expected 'admin' | 'user', received 'boss'")); + } + + #[test] + fn nullable_and_dates() { + let body = json!({ "a": null, "d": "2024-01-01", "n": 1.5 }); + let mut issues = Issues::new(); + assert_eq!(issues.string(&body, "a", Presence::Nullable), Field::Null); + assert_eq!(issues.string(&body, "b", Presence::Nullable), Field::Absent); + assert!(issues.is_empty()); + issues.date(&body, "d", Presence::Optional); + issues.int(&body, "n", Presence::Optional); + assert_eq!( + issues.first_message().as_deref(), + Some("Expected date, received string") + ); + assert!(issues + .zod_message() + .contains("Expected integer, received float")); + } +} diff --git a/server/src/services/openrouter.rs b/server/src/services/openrouter.rs index 34aa97b..821cc83 100644 --- a/server/src/services/openrouter.rs +++ b/server/src/services/openrouter.rs @@ -1,13 +1,20 @@ //! Génération de textes par IA (OpenRouter). //! -//! STUB — à porter depuis `server/services/openrouter.ts`. -#![allow(unused_variables)] +//! Portage de `server/services/openrouter.ts` : trois variantes de texte sont +//! demandées au modèle, puis extraites de sa réponse en essayant plusieurs +//! formats d'en-têtes (« VERSION 1 - DYNAMIQUE », en gras Markdown ou non). use axum::http::StatusCode; -use serde_json::Value; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use crate::state::AppState; +const CHAT_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +const MODELS_URL: &str = "https://openrouter.ai/api/v1/models"; + /// Génère les variantes de texte (`{ variant, text, characterCount }`). pub async fn generate_post_text( state: &AppState, @@ -15,10 +22,478 @@ pub async fn generate_post_text( user_id: &str, model_override: Option<&str>, ) -> anyhow::Result> { - anyhow::bail!("Configuration OpenRouter non trouvée") + // Configuration de l'utilisateur, sinon la dernière configuration enregistrée + let config = match state.db.get_openrouter_config(user_id).await? { + Some(config) => Some(config), + None => state.db.get_any_openrouter_config().await?, + }; + let Some(config) = config else { + anyhow::bail!( + "Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres." + ); + }; + if config.api_key.trim().is_empty() { + anyhow::bail!( + "Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres." + ); + } + + let prompt = build_prompt(product_info, &config.system_prompt); + let model = model_override + .filter(|m| !m.is_empty()) + .unwrap_or(&config.model) + .to_string(); + + let fingerprint = hex::encode(Sha256::digest(config.api_key.as_bytes())); + tracing::info!( + "[OpenRouter] Generating with model=\"{model}\" keyFingerprint={} keyLength={} configUserId={}", + &fingerprint[..8], + config.api_key.chars().count(), + config.user_id + ); + + let referer = state + .config + .app_url + .clone() + .unwrap_or_else(|| "http://localhost:5555".into()); + let response = state + .http + .post(CHAT_URL) + .bearer_auth(config.api_key.trim()) + .header("HTTP-Referer", referer) + .header("X-Title", "Social Flow") + .json(&json!({ + "model": model, + "messages": [{ "role": "user", "content": prompt }], + "temperature": 0.8, + "max_tokens": 1000, + })) + .send() + .await + .inspect_err(|e| tracing::error!("Error generating text with OpenRouter: {e}"))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + tracing::error!("OpenRouter API Error Response: {body}"); + // Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle + // inconnu…) est la seule information exploitable pour l'utilisateur. + anyhow::bail!( + "OpenRouter a refusé la requête ({} {}) avec le modèle \"{model}\" : {}", + status.as_u16(), + status.canonical_reason().unwrap_or(""), + extract_upstream_message(&body) + ); + } + + let data: Value = response.json().await?; + let content = match data.get("choices").and_then(|c| c.get(0)) { + Some(choice) if !choice.is_null() => choice + .pointer("/message/content") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + _ => { + tracing::error!( + "Invalid OpenRouter response - no choices (hasChoices: {}, model: {model})", + data.get("choices").is_some() + ); + anyhow::bail!( + "Réponse invalide de l'API OpenRouter. Vérifiez votre clé API et votre modèle." + ); + } + }; + + Ok(parse_generated_text(&content)) } /// 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()) + let message = error.to_string(); + // Problème de configuration : l'administrateur peut le corriger lui-même + if message.contains("Configuration OpenRouter non trouvée") + || message.contains("Clé API OpenRouter manquante") + { + return (StatusCode::BAD_REQUEST, message); + } + // Refus du service tiers + if message.contains("OpenRouter a refusé la requête") + || message.contains("Réponse invalide de l'API OpenRouter") + { + return (StatusCode::BAD_GATEWAY, message); + } + (StatusCode::INTERNAL_SERVER_ERROR, message) +} + +/// Modèles proposés par OpenRouter (champ `data` de leur API). +pub async fn get_available_models(state: &AppState) -> anyhow::Result> { + let response = state.http.get(MODELS_URL).send().await?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!( + "Failed to fetch models: {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ); + } + let data: Value = response.json().await?; + Ok(match data.get("data") { + Some(Value::Array(models)) => models.clone(), + _ => Vec::new(), + }) +} + +/// Message d'erreur d'une réponse OpenRouter (JSON `{ error: { message } }` ou +/// texte brut), tronqué pour ne pas déverser une page HTML dans l'interface. +fn extract_upstream_message(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return "aucun détail fourni".into(); + } + if let Ok(parsed) = serde_json::from_str::(trimmed) { + let candidates = [ + parsed.pointer("/error/message"), + parsed.get("message"), + parsed.get("error"), + ]; + // Premier champ « truthy », comme `a || b || c` + let message = candidates + .into_iter() + .flatten() + .find(|v| crate::extract::is_truthy(Some(v))); + if let Some(Value::String(message)) = message { + if !message.trim().is_empty() { + return truncate_utf16(message.trim(), 300); + } + } + } + static SPACES: Lazy = Lazy::new(|| Regex::new(r"\s+").unwrap()); + truncate_utf16(&SPACES.replace_all(trimmed, " "), 300) +} + +/// `text.slice(0, max)` en unités UTF-16, sans couper un caractère. +fn truncate_utf16(text: &str, max: usize) -> String { + let mut units = 0; + let mut out = String::new(); + for c in text.chars() { + units += c.len_utf16(); + if units > max { + break; + } + out.push(c); + } + out +} + +/// Longueur JavaScript (`string.length`) : nombre d'unités UTF-16. +fn js_length(text: &str) -> usize { + text.encode_utf16().count() +} + +/// Conversion d'une valeur en texte comme un gabarit JavaScript (`${value}`). +fn js_string(value: Option<&Value>) -> String { + match value { + None => "undefined".into(), + Some(Value::Null) => "null".into(), + Some(Value::String(s)) => s.clone(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::Number(n)) => js_number(n), + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + other => js_string(Some(other)), + }) + .collect::>() + .join(","), + Some(Value::Object(_)) => "[object Object]".into(), + } +} + +fn js_number(n: &serde_json::Number) -> String { + if let Some(i) = n.as_i64() { + return i.to_string(); + } + match n.as_f64() { + Some(f) if f.fract() == 0.0 && f.abs() < 1e21 => format!("{f:.0}"), + Some(f) => f.to_string(), + None => n.to_string(), + } +} + +fn build_prompt(product_info: &Value, system_prompt: &str) -> String { + let truthy = |key: &str| crate::extract::is_truthy(product_info.get(key)); + // `features?.join(", ")` + let features = match product_info.get("features") { + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + other => js_string(Some(other)), + }) + .collect::>() + .join(", "), + _ => String::new(), + }; + + let description = if truthy("description") { + format!( + "Description: {}", + js_string(product_info.get("description")) + ) + } else { + String::new() + }; + let price = if truthy("price") { + format!("Prix: {}", js_string(product_info.get("price"))) + } else { + String::new() + }; + let features = if features.is_empty() { + String::new() + } else { + format!("Caractéristiques: {features}") + }; + let product_details = format!( + "Nom: {}\n{description}\n{price}\n{features}", + js_string(product_info.get("name")) + ); + + format!( + "{system_prompt}\n\nInformations produit:\n{product_details}\n\nFormat de réponse:\nVERSION 1 - DYNAMIQUE:\n[texte]\n\nVERSION 2 - INFORMATIVE:\n[texte]\n\nVERSION 3 - ÉMOTIONNELLE:\n[texte]" + ) +} + +fn variant(name: &str, text: &str) -> Value { + json!({ "variant": name, "text": text, "characterCount": js_length(text) }) +} + +fn parse_generated_text(content: &str) -> Vec { + let normalized = content.replace("\r\n", "\n"); + let normalized = normalized.trim(); + tracing::info!( + "🔍 Parsing AI response (length: {} )", + js_length(normalized) + ); + + let variants = try_multiple_parsing_strategies(normalized); + if !variants.is_empty() { + tracing::info!("✅ Parsed successfully: {} variants", variants.len()); + return variants; + } + + tracing::info!("⚠️ Parsing failed, using fallback"); + vec![variant("Généré par IA", normalized)] +} + +fn try_multiple_parsing_strategies(content: &str) -> Vec { + let strategy1 = parse_by_version_headers(content); + if strategy1.len() >= 3 { + return strategy1; + } + let strategy2 = parse_with_headers(content, true); + if strategy2.len() >= 3 { + return strategy2; + } + let strategy3 = parse_with_headers(content, false); + if strategy3.len() >= 3 { + return strategy3; + } + if !strategy1.is_empty() { + strategy1 + } else if !strategy2.is_empty() { + strategy2 + } else { + strategy3 + } +} + +fn parse_by_version_headers(content: &str) -> Vec { + static HEADER: Lazy = Lazy::new(|| { + Regex::new(r"(?i)(?:\*{2})?VERSION\s+([0-9]+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?") + .unwrap() + }); + static SEPARATORS: Lazy = Lazy::new(|| Regex::new(r"(?m)^[-*]{3,}\s*").unwrap()); + static NOTES: Lazy = Lazy::new(|| Regex::new(r"(?m)^\([^)]+\)\s*").unwrap()); + + let matches: Vec = HEADER.captures_iter(content).collect(); + let mut variants = Vec::new(); + if matches.len() < 3 { + return variants; + } + for (i, caps) in matches.iter().enumerate() { + let whole = caps.get(0).expect("groupe 0"); + let end = matches + .get(i + 1) + .and_then(|next| next.get(0)) + .map(|m| m.start()) + .unwrap_or(content.len()); + let text = content[whole.end()..end].trim(); + let text = SEPARATORS.replace_all(text, ""); + let text = NOTES.replace_all(&text, ""); + let text = text.trim(); + if !text.is_empty() { + let style = capitalize_first(&caps[2].to_lowercase()); + variants.push(variant(&format!("Version {} - {style}", &caps[1]), text)); + } + } + variants +} + +/// Stratégies 2 et 3 : en-têtes attendus un par un, en gras Markdown ou non. +/// Les regex JavaScript utilisaient un lookahead `(?=VERSION 2|$)`, que l'on +/// reproduit en cherchant l'en-tête suivant à partir de la fin du courant. +fn parse_with_headers(content: &str, markdown: bool) -> Vec { + static MD: Lazy<[Regex; 5]> = Lazy::new(|| { + [ + Regex::new(r"(?i)\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+2").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+3").unwrap(), + ] + }); + static PLAIN: Lazy<[Regex; 5]> = Lazy::new(|| { + [ + Regex::new(r"(?i)VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+2").unwrap(), + Regex::new(r"(?i)VERSION\s+3").unwrap(), + ] + }); + let [v1, v2, v3, stop2, stop3] = if markdown { &*MD } else { &*PLAIN }; + + let section = |header: &Regex, stop: Option<&Regex>| -> Option { + let found = header.find(content)?; + let end = stop + .and_then(|s| s.find_at(content, found.end())) + .map(|m| m.start()) + .unwrap_or(content.len()); + Some(content[found.end()..end].trim().to_string()) + }; + + let mut variants = Vec::new(); + if let Some(text) = section(v1, Some(stop2)) { + variants.push(variant("Version 1 - Dynamique", &text)); + } + if let Some(text) = section(v2, Some(stop3)) { + variants.push(variant("Version 2 - Informative", &text)); + } + if let Some(text) = section(v3, None) { + variants.push(variant("Version 3 - Émotionnelle", &text)); + } + variants +} + +fn capitalize_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + &chars.as_str().to_lowercase(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn texts(variants: &[Value]) -> Vec<(String, String)> { + variants + .iter() + .map(|v| { + ( + v["variant"].as_str().unwrap().to_string(), + v["text"].as_str().unwrap().to_string(), + ) + }) + .collect() + } + + #[test] + fn parses_plain_version_headers() { + let content = "VERSION 1 - DYNAMIQUE:\nSuper promo !\n\nVERSION 2 - INFORMATIVE:\n(note)\nProduit de qualité.\n---\nVERSION 3 - ÉMOTIONNELLE:\nUn coup de cœur 😍"; + let parsed = parse_generated_text(content); + assert_eq!( + texts(&parsed), + vec![ + ("Version 1 - Dynamique".into(), "Super promo !".into()), + ( + "Version 2 - Informative".into(), + "Produit de qualité.".into() + ), + ( + "Version 3 - Émotionnelle".into(), + "Un coup de cœur 😍".into() + ), + ] + ); + // Longueur JavaScript : l'émoji compte pour deux + assert_eq!(parsed[2]["characterCount"], 18); + } + + #[test] + fn parses_markdown_headers() { + let content = "**VERSION 1 – DYNAMIQUE**\nA\n\n**VERSION 2 – INFORMATIVE**\nB\n\n**VERSION 3 – émotionnelle**\nC"; + let parsed = parse_generated_text(content); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0]["text"], "A"); + assert_eq!(parsed[2]["variant"], "Version 3 - Émotionnelle"); + } + + #[test] + fn fallback_returns_whole_text() { + let parsed = parse_generated_text(" Juste un texte\r\n "); + assert_eq!( + texts(&parsed), + vec![("Généré par IA".into(), "Juste un texte".into())] + ); + } + + #[test] + fn markdown_lookahead_strategy() { + // Une seule version reconnue par la stratégie 1 : la stratégie 2 prend le relais + let content = "**VERSION 1 - DYNAMIQUE:**\nUn\n**VERSION 2 - INFORMATIV:**\nDeux\n**VERSION 3 - EMOTIONNELL:**\nTrois"; + let parsed = parse_with_headers(content, true); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[1]["text"], "Deux"); + } + + #[test] + fn prompt_matches_template() { + let prompt = build_prompt( + &json!({ "name": "Vase", "price": 12, "features": ["bleu", "verre"] }), + "SYS", + ); + assert!(prompt.starts_with("SYS\n\nInformations produit:\nNom: Vase\n\nPrix: 12\nCaractéristiques: bleu, verre\n\nFormat de réponse:")); + let missing = build_prompt(&json!({}), "S"); + assert!(missing.contains("Nom: undefined\n\n\n\n")); + } + + #[test] + fn upstream_messages() { + assert_eq!(extract_upstream_message(""), "aucun détail fourni"); + assert_eq!( + extract_upstream_message(r#"{"error":{"message":" Invalid key "}}"#), + "Invalid key" + ); + assert_eq!( + extract_upstream_message("\n oops "), + " oops " + ); + } + + #[test] + fn generation_error_statuses() { + let e = anyhow::anyhow!("Configuration OpenRouter non trouvée. Veuillez…"); + assert_eq!(describe_generation_error(&e).0, StatusCode::BAD_REQUEST); + let e = anyhow::anyhow!("OpenRouter a refusé la requête (401 Unauthorized)"); + assert_eq!(describe_generation_error(&e).0, StatusCode::BAD_GATEWAY); + let e = anyhow::anyhow!("boom"); + assert_eq!( + describe_generation_error(&e), + (StatusCode::INTERNAL_SERVER_ERROR, "boom".into()) + ); + } } From b451826ec59056f4dba8126c603a9160fd6a31a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:49:03 +0000 Subject: [PATCH 10/10] Retrait de l'ancien serveur Node/TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur Rust (server/src) le remplace entièrement : routes, services, migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le schéma est désormais créé et migré par server/src/migrate.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- drizzle.config.ts | 14 - init.sql | 57 - migration-docker.sql | 233 -- migrations/0000_spicy_cargill.sql | 151 -- migrations/meta/0000_snapshot.json | 1047 --------- migrations/meta/_journal.json | 13 - server/auth.ts | 45 - server/cron.ts | 51 - server/db.ts | 12 - server/index.ts | 241 -- server/init-admin.ts | 30 - server/middleware/apiKey.ts | 28 - server/migrate.ts | 299 --- server/reproduce_emoji.ts | 32 - server/routes.ts | 2120 ------------------ server/routes/analytics.ts | 79 - server/routes/external.ts | 497 ---- server/routes/facebook.ts | 300 --- server/routes/legal.ts | 269 --- server/routes/reels.ts | 470 ---- server/routes/tiktok.ts | 226 -- server/services/analytics.ts | 297 --- server/services/cloudinary.ts | 204 -- server/services/emojiRenderer.ts | 238 -- server/services/facebook.ts | 840 ------- server/services/facebook_oauth.ts | 295 --- server/services/ffmpeg.ts | 356 --- server/services/freesound.ts | 251 --- server/services/imageProcessor.ts | 301 --- server/services/jamendo.ts | 296 --- server/services/media-purge.ts | 64 - server/services/minio.ts | 143 -- server/services/openrouter.ts | 289 --- server/services/reels/assets.ts | 94 - server/services/reels/index.ts | 12 - server/services/reels/publish.ts | 189 -- server/services/reels/queue.ts | 278 --- server/services/reels/remotionAssets.test.ts | 12 - server/services/reels/remotionAssets.ts | 58 - server/services/reels/remotionRenderer.ts | 85 - server/services/reels/videoPipeline.ts | 160 -- server/services/scheduler.ts | 182 -- server/services/thumbnail.ts | 101 - server/services/tiktok.ts | 574 ----- server/services/token_manager.ts | 358 --- server/services/ttsSync.test.ts | 14 - server/services/ttsSync.ts | 49 - server/services/twemoji-parser.d.ts | 11 - server/src/main.rs | 2 + server/storage.ts | 831 ------- server/utils/encryption.ts | 152 -- server/utils/graph_client.ts | 94 - server/utils/public_url.ts | 47 - server/vite.ts | 85 - 54 files changed, 2 insertions(+), 13174 deletions(-) delete mode 100644 drizzle.config.ts delete mode 100644 init.sql delete mode 100644 migration-docker.sql delete mode 100644 migrations/0000_spicy_cargill.sql delete mode 100644 migrations/meta/0000_snapshot.json delete mode 100644 migrations/meta/_journal.json delete mode 100644 server/auth.ts delete mode 100644 server/cron.ts delete mode 100644 server/db.ts delete mode 100644 server/index.ts delete mode 100644 server/init-admin.ts delete mode 100644 server/middleware/apiKey.ts delete mode 100644 server/migrate.ts delete mode 100644 server/reproduce_emoji.ts delete mode 100644 server/routes.ts delete mode 100644 server/routes/analytics.ts delete mode 100644 server/routes/external.ts delete mode 100644 server/routes/facebook.ts delete mode 100644 server/routes/legal.ts delete mode 100644 server/routes/reels.ts delete mode 100644 server/routes/tiktok.ts delete mode 100644 server/services/analytics.ts delete mode 100644 server/services/cloudinary.ts delete mode 100644 server/services/emojiRenderer.ts delete mode 100644 server/services/facebook.ts delete mode 100644 server/services/facebook_oauth.ts delete mode 100644 server/services/ffmpeg.ts delete mode 100644 server/services/freesound.ts delete mode 100644 server/services/imageProcessor.ts delete mode 100644 server/services/jamendo.ts delete mode 100644 server/services/media-purge.ts delete mode 100644 server/services/minio.ts delete mode 100644 server/services/openrouter.ts delete mode 100644 server/services/reels/assets.ts delete mode 100644 server/services/reels/index.ts delete mode 100644 server/services/reels/publish.ts delete mode 100644 server/services/reels/queue.ts delete mode 100644 server/services/reels/remotionAssets.test.ts delete mode 100644 server/services/reels/remotionAssets.ts delete mode 100644 server/services/reels/remotionRenderer.ts delete mode 100644 server/services/reels/videoPipeline.ts delete mode 100644 server/services/scheduler.ts delete mode 100644 server/services/thumbnail.ts delete mode 100644 server/services/tiktok.ts delete mode 100644 server/services/token_manager.ts delete mode 100644 server/services/ttsSync.test.ts delete mode 100644 server/services/ttsSync.ts delete mode 100644 server/services/twemoji-parser.d.ts delete mode 100644 server/storage.ts delete mode 100644 server/utils/encryption.ts delete mode 100644 server/utils/graph_client.ts delete mode 100644 server/utils/public_url.ts delete mode 100644 server/vite.ts diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 4cd7b4c..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL, ensure the database is provisioned"); -} - -export default defineConfig({ - out: "./migrations", - schema: "./shared/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL, - }, -}); diff --git a/init.sql b/init.sql deleted file mode 100644 index faa59de..0000000 --- a/init.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Script d'initialisation de la base de données Social Flow --- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut - --- IMPORTANT: Pour les serveurs privés existants, ajouter la valeur 'both' à l'enum post_type --- Commande à exécuter manuellement sur votre serveur privé : --- ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both'; - --- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push) -DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('admin', 'user'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - --- Créer l'utilisateur admin par défaut (username: admin, password: admin) --- Note: Le hash bcrypt pour "admin" est généré avec un salt de 10 -INSERT INTO users (id, username, password, role) -VALUES ( - 'admin-user-id', - 'admin', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'admin' -) ON CONFLICT (username) DO NOTHING; - --- Créer un utilisateur demo pour les tests -INSERT INTO users (id, username, password, role) -VALUES ( - 'demo-user', - 'demo', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'user' -) ON CONFLICT (username) DO NOTHING; - --- Créer la table user_page_permissions (permissions d'accès aux pages par utilisateur) -CREATE TABLE IF NOT EXISTS user_page_permissions ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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(), - UNIQUE(user_id, page_id) -); - --- Index pour optimiser les requêtes de permissions -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id); -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id); - --- Liste des tables créées automatiquement par Drizzle ORM : --- - users (utilisateurs avec rôles) --- - social_pages (pages Facebook/Instagram connectées) --- - cloudinary_config (configuration Cloudinary par utilisateur) --- - openrouter_config (configuration OpenRouter par utilisateur) --- - media (fichiers médias stockés dans Cloudinary) --- - posts (publications créées) --- - scheduled_posts (publications programmées) --- - ai_generations (historique des générations IA) --- - post_media (relation many-to-many entre posts et media) --- - user_page_permissions (permissions d'accès aux pages par utilisateur) diff --git a/migration-docker.sql b/migration-docker.sql deleted file mode 100644 index 8a8d05c..0000000 --- a/migration-docker.sql +++ /dev/null @@ -1,233 +0,0 @@ --- ============================================================ --- MIGRATION SQL POUR SERVEUR DOCKER EXISTANT --- Script de mise à jour pour Social Flow --- ============================================================ --- Ce fichier contient toutes les commandes SQL pour mettre à jour --- une base de données existante vers la dernière version du schéma --- ============================================================ - --- ÉTAPE 1: Créer les ENUMs s'ils n'existent pas déjà --- ============================================================ - -DO $$ BEGIN - CREATE TYPE platform AS ENUM ('facebook', 'instagram'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE post_type AS ENUM ('feed', 'story', 'both'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE post_status AS ENUM ('draft', 'scheduled', 'published', 'failed'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE media_type AS ENUM ('image', 'video'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('admin', 'user'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - --- IMPORTANT: Ajouter 'both' à post_type si serveur existant --- Cette valeur a été ajoutée récemment -DO $$ BEGIN - ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both'; -EXCEPTION - WHEN duplicate_object THEN null; - WHEN others THEN null; -END $$; - --- ÉTAPE 2: Créer les tables principales --- ============================================================ - --- Table users -CREATE TABLE IF NOT EXISTS users ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - role user_role NOT NULL DEFAULT 'user' -); - --- Table social_pages -CREATE TABLE IF NOT EXISTS social_pages ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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, - followers_count INTEGER DEFAULT 0, - is_active TEXT NOT NULL DEFAULT 'true', - created_at TIMESTAMP DEFAULT NOW() -); - --- Table cloudinary_config -CREATE TABLE IF NOT EXISTS cloudinary_config ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - cloud_name TEXT NOT NULL, - api_key TEXT NOT NULL, - api_secret TEXT NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table openrouter_config -CREATE TABLE IF NOT EXISTS openrouter_config ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - api_key TEXT NOT NULL, - model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5', - system_prompt TEXT NOT NULL 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.', - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table media -CREATE TABLE IF NOT EXISTS media ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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, - file_name TEXT NOT NULL, - file_size INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT NOW() -); - --- Table posts -CREATE TABLE IF NOT EXISTS posts ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - ai_generated TEXT NOT NULL DEFAULT 'false', - product_info JSONB, - status post_status NOT NULL DEFAULT 'draft', - scheduled_for TIMESTAMP, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table post_media -CREATE TABLE IF NOT EXISTS post_media ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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 NOT NULL DEFAULT 0 -); - --- Table scheduled_posts -CREATE TABLE IF NOT EXISTS scheduled_posts ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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 -); - --- Table ai_generations -CREATE TABLE IF NOT EXISTS ai_generations ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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() -); - --- Table user_page_permissions -CREATE TABLE IF NOT EXISTS user_page_permissions ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - 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(), - UNIQUE(user_id, page_id) -); - --- ÉTAPE 3: Créer les index pour optimiser les performances --- ============================================================ - --- Index pour user_page_permissions -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id); -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id); - --- Index pour scheduled_posts -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_scheduled_at ON scheduled_posts(scheduled_at); -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_post_id ON scheduled_posts(post_id); -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_page_id ON scheduled_posts(page_id); - --- Index pour posts -CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); -CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); -CREATE INDEX IF NOT EXISTS idx_posts_scheduled_for ON posts(scheduled_for); - --- Index pour media -CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id); - --- ÉTAPE 4: Créer les utilisateurs par défaut --- ============================================================ - --- Créer l'utilisateur admin (username: admin, password: admin) -INSERT INTO users (id, username, password, role) -VALUES ( - 'admin-user-id', - 'admin', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'admin' -) ON CONFLICT (username) DO NOTHING; - --- Créer un utilisateur demo (username: demo, password: admin) -INSERT INTO users (id, username, password, role) -VALUES ( - 'demo-user', - 'demo', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'user' -) ON CONFLICT (username) DO NOTHING; - --- ÉTAPE 5: Mise à jour des posts programmés existants (si migration) --- ============================================================ --- Mettre à jour le statut des posts programmés qui étaient en "draft" -UPDATE posts -SET status = 'scheduled' -WHERE scheduled_for IS NOT NULL - AND status = 'draft'; - --- ============================================================ --- FIN DE LA MIGRATION --- ============================================================ --- --- INSTRUCTIONS D'UTILISATION: --- --- 1. Pour un NOUVEAU serveur Docker: --- - Rien à faire ! Le docker-compose.yml exécute automatiquement --- "drizzle-kit push --force" qui crée toutes les tables --- --- 2. Pour un serveur Docker EXISTANT avec données: --- - Connectez-vous à PostgreSQL: --- docker exec -it socialflow-db psql -U socialflow -d socialflow --- - Exécutez ce fichier: --- \i /chemin/vers/migration-docker.sql --- --- 3. Alternative avec commande directe: --- docker exec -i socialflow-db psql -U socialflow -d socialflow < migration-docker.sql --- --- ============================================================ diff --git a/migrations/0000_spicy_cargill.sql b/migrations/0000_spicy_cargill.sql deleted file mode 100644 index 2c7c1fd..0000000 --- a/migrations/0000_spicy_cargill.sql +++ /dev/null @@ -1,151 +0,0 @@ -CREATE TYPE "public"."media_type" AS ENUM('image', 'video');--> statement-breakpoint -CREATE TYPE "public"."platform" AS ENUM('facebook', 'instagram');--> statement-breakpoint -CREATE TYPE "public"."post_status" AS ENUM('draft', 'scheduled', 'published', 'failed');--> statement-breakpoint -CREATE TYPE "public"."post_type" AS ENUM('feed', 'story', 'both');--> statement-breakpoint -CREATE TYPE "public"."token_status" AS ENUM('valid', 'expiring', 'expired', 'error');--> statement-breakpoint -CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');--> statement-breakpoint -CREATE TABLE "ai_generations" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "product_info" jsonb NOT NULL, - "generated_texts" jsonb NOT NULL, - "selected_text" text, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "cloudinary_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "cloud_name" text NOT NULL, - "api_key" text NOT NULL, - "api_secret" text NOT NULL, - "logo_public_id" text, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT "cloudinary_config_user_id_unique" UNIQUE("user_id") -); ---> statement-breakpoint -CREATE TABLE "media" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "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, - "file_name" text NOT NULL, - "file_size" integer NOT NULL, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "openrouter_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "api_key" text NOT NULL, - "model" text DEFAULT 'anthropic/claude-3.5-sonnet' 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") -); ---> statement-breakpoint -CREATE TABLE "page_analytics_history" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "page_id" varchar NOT NULL, - "date" timestamp NOT NULL, - "followers_count" integer DEFAULT 0, - "page_views" integer DEFAULT 0, - "page_reach" integer DEFAULT 0, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "post_analytics" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "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 -); ---> statement-breakpoint -CREATE TABLE "post_media" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "media_id" varchar NOT NULL, - "display_order" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "posts" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "content" text NOT NULL, - "ai_generated" text DEFAULT 'false' NOT NULL, - "product_info" jsonb, - "status" "post_status" DEFAULT 'draft' NOT NULL, - "scheduled_for" timestamp, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "scheduled_posts" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "page_id" varchar NOT NULL, - "post_type" "post_type" NOT NULL, - "scheduled_at" timestamp NOT NULL, - "published_at" timestamp, - "external_post_id" text, - "error" text -); ---> statement-breakpoint -CREATE TABLE "social_pages" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "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, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "user_page_permissions" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "page_id" varchar NOT NULL, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "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") -); ---> statement-breakpoint -ALTER TABLE "ai_generations" ADD CONSTRAINT "ai_generations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "cloudinary_config" ADD CONSTRAINT "cloudinary_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "media" ADD CONSTRAINT "media_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "openrouter_config" ADD CONSTRAINT "openrouter_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "page_analytics_history" ADD CONSTRAINT "page_analytics_history_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_analytics" ADD CONSTRAINT "post_analytics_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_media" ADD CONSTRAINT "post_media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_media" ADD CONSTRAINT "post_media_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "social_pages" ADD CONSTRAINT "social_pages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json deleted file mode 100644 index a49f52c..0000000 --- a/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,1047 +0,0 @@ -{ - "id": "0e3307c4-094d-4ea4-a1dc-8bd9a4d96401", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.ai_generations": { - "name": "ai_generations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "product_info": { - "name": "product_info", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "generated_texts": { - "name": "generated_texts", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "selected_text": { - "name": "selected_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ai_generations_user_id_users_id_fk": { - "name": "ai_generations_user_id_users_id_fk", - "tableFrom": "ai_generations", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.cloudinary_config": { - "name": "cloudinary_config", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "cloud_name": { - "name": "cloud_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_key": { - "name": "api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_secret": { - "name": "api_secret", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo_public_id": { - "name": "logo_public_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "cloudinary_config_user_id_users_id_fk": { - "name": "cloudinary_config_user_id_users_id_fk", - "tableFrom": "cloudinary_config", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "cloudinary_config_user_id_unique": { - "name": "cloudinary_config_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.media": { - "name": "media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "media_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "cloudinary_public_id": { - "name": "cloudinary_public_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_url": { - "name": "original_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "facebook_landscape_url": { - "name": "facebook_landscape_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "facebook_feed_url": { - "name": "facebook_feed_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instagram_feed_url": { - "name": "instagram_feed_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instagram_story_url": { - "name": "instagram_story_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "file_size": { - "name": "file_size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "media_user_id_users_id_fk": { - "name": "media_user_id_users_id_fk", - "tableFrom": "media", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openrouter_config": { - "name": "openrouter_config", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "api_key": { - "name": "api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'anthropic/claude-3.5-sonnet'" - }, - "system_prompt": { - "name": "system_prompt", - "type": "text", - "primaryKey": false, - "notNull": true, - "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.'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "openrouter_config_user_id_users_id_fk": { - "name": "openrouter_config_user_id_users_id_fk", - "tableFrom": "openrouter_config", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "openrouter_config_user_id_unique": { - "name": "openrouter_config_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.page_analytics_history": { - "name": "page_analytics_history", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "date": { - "name": "date", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "followers_count": { - "name": "followers_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "page_views": { - "name": "page_views", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "page_reach": { - "name": "page_reach", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "page_analytics_history_page_id_social_pages_id_fk": { - "name": "page_analytics_history_page_id_social_pages_id_fk", - "tableFrom": "page_analytics_history", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.post_analytics": { - "name": "post_analytics", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "fetched_at": { - "name": "fetched_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "impressions": { - "name": "impressions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "reach": { - "name": "reach", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "engagement": { - "name": "engagement", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "reactions": { - "name": "reactions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "comments": { - "name": "comments", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "shares": { - "name": "shares", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "clicks": { - "name": "clicks", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "raw_data": { - "name": "raw_data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "post_analytics_post_id_posts_id_fk": { - "name": "post_analytics_post_id_posts_id_fk", - "tableFrom": "post_analytics", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.post_media": { - "name": "post_media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "media_id": { - "name": "media_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "display_order": { - "name": "display_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "post_media_post_id_posts_id_fk": { - "name": "post_media_post_id_posts_id_fk", - "tableFrom": "post_media", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "post_media_media_id_media_id_fk": { - "name": "post_media_media_id_media_id_fk", - "tableFrom": "post_media", - "tableTo": "media", - "columnsFrom": [ - "media_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.posts": { - "name": "posts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ai_generated": { - "name": "ai_generated", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'false'" - }, - "product_info": { - "name": "product_info", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "post_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'draft'" - }, - "scheduled_for": { - "name": "scheduled_for", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "posts_user_id_users_id_fk": { - "name": "posts_user_id_users_id_fk", - "tableFrom": "posts", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.scheduled_posts": { - "name": "scheduled_posts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "post_type": { - "name": "post_type", - "type": "post_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "scheduled_at": { - "name": "scheduled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "published_at": { - "name": "published_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "external_post_id": { - "name": "external_post_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "scheduled_posts_post_id_posts_id_fk": { - "name": "scheduled_posts_post_id_posts_id_fk", - "tableFrom": "scheduled_posts", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "scheduled_posts_page_id_social_pages_id_fk": { - "name": "scheduled_posts_page_id_social_pages_id_fk", - "tableFrom": "scheduled_posts", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.social_pages": { - "name": "social_pages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "platform": { - "name": "platform", - "type": "platform", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "page_name": { - "name": "page_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_expires_at": { - "name": "token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "followers_count": { - "name": "followers_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "is_active": { - "name": "is_active", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'true'" - }, - "token_status": { - "name": "token_status", - "type": "token_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'valid'" - }, - "last_token_check": { - "name": "last_token_check", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "social_pages_user_id_users_id_fk": { - "name": "social_pages_user_id_users_id_fk", - "tableFrom": "social_pages", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_page_permissions": { - "name": "user_page_permissions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_page_permissions_user_id_users_id_fk": { - "name": "user_page_permissions_user_id_users_id_fk", - "tableFrom": "user_page_permissions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_page_permissions_page_id_social_pages_id_fk": { - "name": "user_page_permissions_page_id_social_pages_id_fk", - "tableFrom": "user_page_permissions", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "user_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'user'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.media_type": { - "name": "media_type", - "schema": "public", - "values": [ - "image", - "video" - ] - }, - "public.platform": { - "name": "platform", - "schema": "public", - "values": [ - "facebook", - "instagram" - ] - }, - "public.post_status": { - "name": "post_status", - "schema": "public", - "values": [ - "draft", - "scheduled", - "published", - "failed" - ] - }, - "public.post_type": { - "name": "post_type", - "schema": "public", - "values": [ - "feed", - "story", - "both" - ] - }, - "public.token_status": { - "name": "token_status", - "schema": "public", - "values": [ - "valid", - "expiring", - "expired", - "error" - ] - }, - "public.user_role": { - "name": "user_role", - "schema": "public", - "values": [ - "admin", - "user" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json deleted file mode 100644 index f9d6f4f..0000000 --- a/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1768914136854, - "tag": "0000_spicy_cargill", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts deleted file mode 100644 index 7d35fcc..0000000 --- a/server/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import passport from "passport"; -import { Strategy as LocalStrategy } from "passport-local"; -import bcrypt from "bcrypt"; -import { storage } from "./storage"; -import type { User } from "@shared/schema"; - -// Configuration de la stratégie locale -passport.use( - new LocalStrategy(async (username, password, done) => { - try { - const user = await storage.getUserByUsername(username); - - if (!user) { - return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" }); - } - - const isValidPassword = await bcrypt.compare(password, user.password); - - if (!isValidPassword) { - return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" }); - } - - return done(null, user); - } catch (error) { - return done(error); - } - }) -); - -// Sérialisation de l'utilisateur pour la session -passport.serializeUser((user: Express.User, done) => { - done(null, (user as User).id); -}); - -// Désérialisation de l'utilisateur depuis la session -passport.deserializeUser(async (id: string, done) => { - try { - const user = await storage.getUser(id); - done(null, user); - } catch (error) { - done(error); - } -}); - -export default passport; diff --git a/server/cron.ts b/server/cron.ts deleted file mode 100644 index 03e41be..0000000 --- a/server/cron.ts +++ /dev/null @@ -1,51 +0,0 @@ -import cron from 'node-cron'; -import { TokenManager } from './services/token_manager'; -import { AnalyticsService } from './services/analytics'; -import { purgeOldMedia } from './services/media-purge'; -import { tiktokService } from './services/tiktok'; - -export function startTokenCron() { - // Toutes les 2 minutes — suivi des publications TikTok (API asynchrone) - cron.schedule('*/2 * * * *', async () => { - try { - await tiktokService.syncPendingPublications(); - } catch (error) { - console.error('[Cron] TikTok publish status sync failed:', error); - } - }); - console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).'); - - // Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons. - // Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour - // réduit d'autant la fenêtre pendant laquelle une publication échouerait. - cron.schedule('0 0,12 * * *', async () => { - console.log('[Cron] Running token check...'); - const results = await TokenManager.checkAndRefreshTokens(); - const renewed = results.filter(r => r.renewed).length; - const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length; - console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`); - }); - console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).'); - - // Run twice daily (08:00 and 20:00) — Analytics sync for all pages - cron.schedule('0 8,20 * * *', async () => { - console.log('[Cron] Running analytics sync for all pages...'); - try { - await AnalyticsService.syncAllPages(); - } catch (error) { - console.error('[Cron] Analytics sync failed:', error); - } - }); - console.log('[Cron] Analytics sync job scheduled (0 8,20 * * *).'); - - // Run every day at 03:00 AM — Cleanup old media from Cloudinary and DB - cron.schedule('0 3 * * *', async () => { - console.log('[Cron] Running daily media purge check...'); - try { - await purgeOldMedia(); - } catch (error) { - console.error('[Cron] Media purge check failed:', error); - } - }); - console.log('[Cron] Media purge job scheduled (0 3 * * *).'); -} diff --git a/server/db.ts b/server/db.ts deleted file mode 100644 index d74a15a..0000000 --- a/server/db.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pool } from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from "@shared/schema"; - -if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL must be set. Did you forget to provision a database?", - ); -} - -export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -export const db = drizzle(pool, { schema }); \ No newline at end of file diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 8f52097..0000000 --- a/server/index.ts +++ /dev/null @@ -1,241 +0,0 @@ -import express, { type Request, Response, NextFunction } from "express"; -import path from "path"; -import fs from "fs"; -import session from "express-session"; -import connectPgSimple from "connect-pg-simple"; -import pg from "pg"; -import crypto from "crypto"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import passport from "./auth"; -import { registerRoutes } from "./routes"; -import { setupVite, serveStatic, log } from "./vite"; -import { schedulerService } from "./services/scheduler"; -import { ensureAdminUserExists } from "./init-admin"; -import { startTokenCron } from "./cron"; -import { migrate } from "./migrate"; -import { startReelProcessing } from "./services/reels"; -import { jamendoService } from "./services/jamendo"; -import { ffmpegService } from "./services/ffmpeg"; - -const app = express(); -const PgSession = connectPgSimple(session); - -// Faire confiance au reverse proxy (nginx) pour X-Forwarded-For -// Nécessaire pour que express-rate-limit identifie correctement les IPs -app.set('trust proxy', 1); - -// Headers de sécurité HTTP avec helmet -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], - imgSrc: ["'self'", "data:", "https:", "blob:"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "wss:", "ws:"], - mediaSrc: ["'self'", "https:", "data:"], - } - }, - crossOriginEmbedderPolicy: false, -})); - -// Rate limiting global - 1000 requêtes par 15 minutes par IP -const globalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 1000, - message: { error: 'Trop de requêtes, réessayez plus tard' }, - standardHeaders: true, - legacyHeaders: false, -}); - -// Rate limiting pour l'authentification - 30 tentatives par 15 minutes -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 30, - message: { error: 'Trop de tentatives de connexion, réessayez dans 15 minutes' }, - standardHeaders: true, - legacyHeaders: false, -}); - -app.use('/api/', globalLimiter); -app.use('/api/auth/login', authLimiter); - -// Serve locally stored audio files -const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio'; -app.use('/uploads/audio', express.static(AUDIO_UPLOAD_DIR)); - -// Serve locally stored media files (images, videos, logos, stories) -const MEDIA_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'media'); -const LOGOS_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'logos'); -const STORIES_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'stories'); -try { fs.mkdirSync(MEDIA_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -try { fs.mkdirSync(LOGOS_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -try { fs.mkdirSync(STORIES_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -app.use('/uploads/media', express.static(MEDIA_UPLOAD_DIR)); -app.use('/uploads/logos', express.static(LOGOS_UPLOAD_DIR)); -app.use('/uploads/stories', express.static(STORIES_UPLOAD_DIR)); - -// Serve temp uploads for Remotion -const TEMP_UPLOAD_DIR = process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), 'uploads', 'temp'); -try { fs.mkdirSync(TEMP_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -app.use('/uploads/temp', express.static(TEMP_UPLOAD_DIR)); - -declare module 'http' { - interface IncomingMessage { - rawBody: unknown - } -} -app.use(express.json({ - verify: (req, _res, buf) => { - req.rawBody = buf; - } -})); -app.use(express.urlencoded({ extended: false })); - -// Validation renforcée du SESSION_SECRET -if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') { - console.error('❌ SESSION_SECRET non défini en production. Arrêt du serveur.'); - process.exit(1); -} - -if (!process.env.SESSION_SECRET) { - console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé aléatoire pour le développement.'); -} - -// Générer un secret aléatoire pour le dev si non défini -const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); - -// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par -// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui -// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL. -const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto'; - -// Configuration du store de session pour production -const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL - ? new PgSession({ - pool: new pg.Pool({ - connectionString: process.env.DATABASE_URL, - }), - tableName: 'session', - createTableIfMissing: true, - }) - : undefined; // MemoryStore par défaut en dev - -app.use(session({ - store: sessionStore, - secret: sessionSecret, - resave: false, - saveUninitialized: false, - cookie: { - secure: isHttps, - httpOnly: true, - sameSite: 'lax', - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours - } -})); - -// Initialisation de Passport -app.use(passport.initialize()); -app.use(passport.session()); - -app.use((req, res, next) => { - const start = Date.now(); - const path = req.path; - let capturedJsonResponse: Record | undefined = undefined; - - const originalResJson = res.json; - res.json = function (bodyJson, ...args) { - capturedJsonResponse = bodyJson; - return originalResJson.apply(res, [bodyJson, ...args]); - }; - - res.on("finish", () => { - const duration = Date.now() - start; - if (path.startsWith("/api")) { - // Ne pas logger les 401 sur /api/auth/session (vérification normale de session) - if (path === "/api/auth/session" && res.statusCode === 401) { - return; - } - - let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; - if (capturedJsonResponse) { - logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; - } - - if (logLine.length > 80) { - logLine = logLine.slice(0, 79) + "…"; - } - - log(logLine); - } - }); - - next(); -}); - -(async () => { - // Run database migration first - await migrate(); - - const server = await registerRoutes(app); - - app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { - const status = err.status || err.statusCode || 500; - const message = err.message || "Internal Server Error"; - - res.status(status).json({ message }); - throw err; - }); - - // importantly only setup vite in development and after - // setting up all the other routes so the catch-all route - // doesn't interfere with the other routes - if (app.get("env") === "development") { - await setupVite(app, server); - } else { - serveStatic(app); - } - - // ALWAYS serve the app on the port specified in the environment variable PORT - // Other ports are firewalled. Default to 5000 if not specified. - // this serves both the API and the client. - // It is the only port that is not firewalled. - const port = parseInt(process.env.PORT || '5000', 10); - - // Initialiser l'utilisateur admin par défaut avant de démarrer le serveur - await ensureAdminUserExists(); - - // Configurer le service Jamendo pour les Reels - const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49'; - jamendoService.configure(jamendoClientId); - - - // Configurer le service FFmpeg pour le traitement vidéo (si configuré) - if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) { - ffmpegService.configure(process.env.FFMPEG_API_URL, process.env.FFMPEG_API_KEY); - } else { - log('⚠️ FFmpeg API not configured - video processing will be skipped'); - } - - server.listen({ - port, - host: "0.0.0.0", - reusePort: true, - }, () => { - log(`serving on port ${port}`); - - // Démarrer le scheduler pour les publications programmées - schedulerService.start(); - - // Start Token Refresh Cron - startTokenCron(); - - // File des rendus de Reels : démarrée une fois le serveur à l'écoute, car - // le rendu d'images lit la voix et la musique via http://localhost. - startReelProcessing().catch((error) => { - console.error("❌ Démarrage de la file des Reels impossible :", error); - }); - }); -})(); diff --git a/server/init-admin.ts b/server/init-admin.ts deleted file mode 100644 index edc43a7..0000000 --- a/server/init-admin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { storage } from "./storage"; -import bcrypt from "bcrypt"; - -export async function ensureAdminUserExists() { - try { - const adminUser = await storage.getUserByUsername("admin"); - - if (!adminUser) { - console.log("🔧 Création de l'utilisateur admin par défaut..."); - - const hashedPassword = await bcrypt.hash("admin", 10); - - await storage.createUser({ - username: "admin", - password: hashedPassword, - role: "admin", - }); - - console.log("✅ Utilisateur admin créé avec succès"); - console.log(" Username: admin"); - console.log(" Password: admin"); - console.log(" ⚠️ IMPORTANT: Changez ce mot de passe immédiatement !"); - } else { - console.log("✓ Utilisateur admin existe déjà"); - } - } catch (error) { - console.error("❌ Erreur lors de la vérification/création de l'utilisateur admin:", error); - throw error; - } -} diff --git a/server/middleware/apiKey.ts b/server/middleware/apiKey.ts deleted file mode 100644 index d01aa0a..0000000 --- a/server/middleware/apiKey.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Request, Response, NextFunction } from "express"; -import { storage } from "../storage"; - -export async function requireApiKey(req: Request, res: Response, next: NextFunction) { - const provided = req.headers["x-api-key"]; - - if (!provided) { - return res.status(401).json({ error: "Clé API manquante (header X-API-Key requis)" }); - } - - try { - const config = await storage.getAppConfig(); - const key = config?.externalApiKey || process.env.EXTERNAL_API_KEY; - - if (!key) { - return res.status(503).json({ error: "API externe non configurée" }); - } - - if (provided !== key) { - return res.status(401).json({ error: "Clé API invalide" }); - } - - next(); - } catch (error) { - console.error("[apiKey middleware] Error:", error); - return res.status(500).json({ error: "Erreur interne lors de la vérification de la clé API" }); - } -} diff --git a/server/migrate.ts b/server/migrate.ts deleted file mode 100644 index 693e4eb..0000000 --- a/server/migrate.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { pool } from "./db"; - -export async function migrate() { - const client = await pool.connect(); - try { - console.log("[Migration] Starting safe migration Check..."); - - // 1. Create Enums if not exist and Update Enums - await client.query(` - DO $$ BEGIN - CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error'); - EXCEPTION - WHEN duplicate_object THEN null; - END $$; - `); - - // Add 'reel' to post_type enum if not exists - await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`); - - // Add 'tiktok' to platform enum if not exists - await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`); - - // 2. Create Tables if not exist - - // post_analytics - await client.query(` - 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 - ); - `); - - // page_analytics_history - await client.query(` - 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, - "created_at" timestamp DEFAULT now() - ); - `); - - // 3. Add Columns to existing tables if not exist - - // social_pages.token_status - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid'; - `); - - // social_pages.last_token_check - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "last_token_check" timestamp; - `); - - // music_favorites - await client.query(` - 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() - ); - `); - - // freesound_config - await client.query(` - CREATE TABLE IF NOT EXISTS "freesound_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, - "client_id" text NOT NULL, - "client_secret" text NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT freesound_config_user_id_unique UNIQUE ("user_id") - ); - `); - - // posts.generation_status (Reel progress tracking) - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_status" text; - `); - - // posts.generation_progress - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0; - `); - - // posts.generation_error - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_error" text; - `); - - // cloudinary_config.endpoint_url (MinIO internal endpoint URL) - await client.query(` - ALTER TABLE "cloudinary_config" - ADD COLUMN IF NOT EXISTS "endpoint_url" text; - `); - - // cloudinary_config.public_url (MinIO public-facing base URL) - await client.query(` - ALTER TABLE "cloudinary_config" - ADD COLUMN IF NOT EXISTS "public_url" text; - `); - // page_analytics_history.page_engagement - await client.query(` - ALTER TABLE "page_analytics_history" - ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0; - `); - - // audio_tracks (bibliothèque audio interne) - await client.query(` - 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() - ); - `); - - // app_config (global application settings) - await client.query(` - CREATE TABLE IF NOT EXISTS "app_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "external_api_key" text, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - - // app_config.gemini_api_key - await client.query(` - ALTER TABLE "app_config" - ADD COLUMN IF NOT EXISTS "gemini_api_key" text; - `); - - // app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS) - await client.query(` - ALTER TABLE "app_config" - ADD COLUMN IF NOT EXISTS "qwen_tts_url" text, - ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text; - `); - - // media.thumbnail_url (vignette conservée après suppression de la vidéo) - await client.query(` - ALTER TABLE "media" - ADD COLUMN IF NOT EXISTS "thumbnail_url" text; - `); - - // tiktok_config (application développeur TikTok, globale) - await client.query(` - 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() - ); - `); - - // social_pages : champs OAuth TikTok - await client.query(` - 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; - `); - - // facebook_config (application développeur Facebook, globale) - await client.query(` - 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() - ); - `); - - // social_pages : renouvellement automatique des tokens Facebook - await client.query(` - 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; - `); - - // scheduled_posts : suivi de la publication asynchrone TikTok - await client.query(` - ALTER TABLE "scheduled_posts" - ADD COLUMN IF NOT EXISTS "publish_id" text, - ADD COLUMN IF NOT EXISTS "publish_status" text; - `); - - // reel_jobs : file d'attente persistante des rendus de Reels - await client.query(` - 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() - ); - `); - - // 4. Index de performance - // - // Postgres n'indexe pas les clés étrangères tout seul : sans ces index, le - // calendrier, l'historique et le scheduler faisaient des parcours complets - // de table à chaque appel. Ils existaient dans migration-docker.sql, qui - // n'est monté nulle part — ce fichier-ci est le seul exécuté au démarrage. - // - // Chaque index est créé isolément : une table absente sur une vieille - // installation ne doit pas empêcher la création des suivants. - const indexes: [string, string][] = [ - // Filtres par propriétaire - ["idx_posts_user_id", `CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")`], - ["idx_media_user_id", `CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")`], - ["idx_social_pages_user_id", `CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")`], - ["idx_ai_generations_user_id", `CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")`], - - // Jointures - ["idx_scheduled_posts_post_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")`], - ["idx_post_media_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")`], - ["idx_post_media_media_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")`], - ["idx_post_analytics_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")`], - ["idx_page_analytics_history_page_date", `CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")`], - ["idx_user_page_permissions_user_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")`], - ["idx_user_page_permissions_page_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")`], - - // Calendrier et historique : « les publications de ces pages, sur cette période » - ["idx_scheduled_posts_page_scheduled_at", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")`], - - // Scheduler (toutes les minutes) : seules les publications non encore - // traitées comptent, d'où un index partiel qui reste minuscule. - ["idx_scheduled_posts_pending", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL`], - - // Suivi asynchrone TikTok (toutes les deux minutes) - ["idx_scheduled_posts_publish_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL`], - - // Reels en cours de génération - ["idx_reel_jobs_queue", `CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')`], - ["idx_posts_generation_status", `CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL`], - ]; - - for (const [name, statement] of indexes) { - try { - await client.query(statement); - } catch (error) { - console.warn(`[Migration] Index ${name} non créé:`, error instanceof Error ? error.message : error); - } - } - - console.log("[Migration] Safe migration completed."); - } catch (error) { - console.error("[Migration] Error during migration:", error); - // Don't kill the process, let the app try to start even if migration failed - // (It might have failed because tables already existed in a different state) - } finally { - client.release(); - } -} diff --git a/server/reproduce_emoji.ts b/server/reproduce_emoji.ts deleted file mode 100644 index 2bd1008..0000000 --- a/server/reproduce_emoji.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import { emojiRenderer } from "./services/emojiRenderer"; -import { createCanvas } from "canvas"; -import fs from "fs"; -import path from "path"; - -async function run() { - const width = 500; - const height = 200; - const canvas = createCanvas(width, height); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, width, height); - - ctx.fillStyle = "black"; - ctx.font = "30px Arial"; - - const text = "Hello 🌍 World! 😂"; - console.log(`Rendering text: ${text}`); - - try { - await emojiRenderer.drawText(text, ctx, 50, 100, 30); - const buffer = canvas.toBuffer("image/png"); - fs.writeFileSync("emoji_test.png", buffer); - console.log("Image saved to emoji_test.png"); - } catch (error) { - console.error("Error rendering emoji:", error); - } -} - -run(); diff --git a/server/routes.ts b/server/routes.ts deleted file mode 100644 index aaddc80..0000000 --- a/server/routes.ts +++ /dev/null @@ -1,2120 +0,0 @@ -import express, { type Express, type Request, type Response, type NextFunction } from "express"; -import { createServer, type Server } from "http"; -import fs from "fs"; -import os from "os"; -import path from "path"; -import { storage } from "./storage"; -import { db, pool } from "./db"; -import multer from "multer"; -import bcrypt from "bcrypt"; -import passport from "./auth"; -import { z } from "zod"; -import { openRouterService, describeGenerationError } from "./services/openrouter"; -import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio"; -import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; -import type { User, InsertUser, ScheduledPost } from "@shared/schema"; -import { analyticsRouter } from "./routes/analytics"; -import { normalizeQwenUrl } from "./services/reels/assets"; -import { reelsRouter } from "./routes/reels"; -import { externalRouter } from "./routes/external"; -import { tiktokRouter } from "./routes/tiktok"; -import { facebookRouter } from "./routes/facebook"; -import { legalRouter } from "./routes/legal"; -import { createVideoThumbnail } from "./services/thumbnail"; -import { insertAudioTrackSchema } from "@shared/schema"; -import { agendaItemFromScheduledPost, agendaItemsFromReelJob, type AgendaItem, type AgendaPage } from "@shared/agenda"; -import * as musicMetadata from "music-metadata"; - -// Types MIME autorisés pour les uploads -const ALLOWED_MIME_TYPES = [ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - 'video/mp4', 'video/quicktime', 'video/webm' -]; - -// Types MIME autorisés pour l'audio -const ALLOWED_AUDIO_MIME_TYPES = [ - 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg' -]; - -// Configuration multer avec validation de taille et type -// Configuration multer avec validation de taille (4GB) et stockage disque temporaire -const upload = multer({ - storage: multer.diskStorage({ - destination: os.tmpdir(), - filename: (req, file, cb) => { - // Nettoyer le nom de fichier pour éviter les problèmes d'encodage - const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_'); - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, uniqueSuffix + '-' + safeName); - } - }), - limits: { - fileSize: 4 * 1024 * 1024 * 1024, // 4GB max - files: 10 // 10 fichiers max - }, - fileFilter: (req, file, cb) => { - // Accepter plus de formats vidéo si nécessaire (ex: mkv, avi) pour le transcodage - // Mais on garde les limites actuelles pour l'instant - if (ALLOWED_MIME_TYPES.includes(file.mimetype) || file.mimetype === 'application/octet-stream') { // iOS envoie parfois octet-stream - cb(null, true); - } else { - cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`)); - } - } -}); - -// Setup audio upload with different filters -// Files saved directly to the persistent local folder -const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio'; - -// Ensure the directory exists at startup -import { mkdirSync } from 'fs'; -try { mkdirSync(AUDIO_UPLOAD_DIR, { recursive: true }); } catch { /* already exists */ } - -const audioUpload = multer({ - storage: multer.diskStorage({ - destination: AUDIO_UPLOAD_DIR, - filename: (req, file, cb) => { - const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_'); - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, uniqueSuffix + '-' + safeName); - } - }), - limits: { - fileSize: 50 * 1024 * 1024, // 50MB max for audio - files: 20 - }, - fileFilter: (req, file, cb) => { - if (ALLOWED_AUDIO_MIME_TYPES.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg)$/i)) { - cb(null, true); - } else { - cb(new Error(`Type de fichier audio non autorisé: ${file.mimetype}`)); - } - } -}); - -// Middleware pour vérifier l'authentification -function requireAuth(req: Request, res: Response, next: NextFunction) { - if (!req.isAuthenticated()) { - return res.status(401).json({ error: "Non authentifié" }); - } - next(); -} - -// Middleware pour vérifier le rôle admin -function requireAdmin(req: Request, res: Response, next: NextFunction) { - if (!req.isAuthenticated()) { - return res.status(401).json({ error: "Non authentifié" }); - } - const user = req.user as User; - if (user.role !== "admin") { - return res.status(403).json({ error: "Accès refusé. Réservé aux administrateurs." }); - } - next(); -} - -/** Convertit un paramètre de requête en entier positif, ou `undefined`. */ -function parsePositiveInt(value: unknown): number | undefined { - if (typeof value !== "string" || value === "") return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; -} - -/** Convertit un paramètre de requête en date, ou `undefined` s'il est inutilisable. */ -function parseDateParam(value: unknown): Date | undefined { - if (typeof value !== "string" || value === "") return undefined; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? undefined : date; -} - -export async function registerRoutes(app: Express): Promise { - - - - // Routes d'authentification - app.post("/api/auth/login", (req, res, next) => { - passport.authenticate("local", (err: any, user: User | false, info: any) => { - if (err) { - return next(err); - } - if (!user) { - return res.status(401).json({ error: info?.message || "Authentification échouée" }); - } - req.logIn(user, (err) => { - if (err) { - return next(err); - } - return res.json({ - id: user.id, - username: user.username, - role: user.role, - }); - }); - })(req, res, next); - }); - - app.post("/api/auth/logout", (req, res) => { - req.logout((err) => { - if (err) { - return res.status(500).json({ error: "Erreur lors de la déconnexion" }); - } - res.json({ message: "Déconnecté avec succès" }); - }); - }); - - app.get("/api/auth/session", (req, res) => { - if (req.isAuthenticated()) { - const user = req.user as User; - res.json({ - id: user.id, - username: user.username, - role: user.role, - }); - } else { - res.status(401).json({ error: "Non authentifié" }); - } - }); - - // Route pour obtenir la liste de tous les utilisateurs (réservée aux admins) - app.get("/api/users", requireAdmin, async (req, res) => { - try { - const allUsers = await storage.getAllUsers(); - - // Ne pas envoyer les mots de passe - const safeUsers = allUsers.map(user => ({ - id: user.id, - username: user.username, - role: user.role, - })); - - res.json(safeUsers); - } catch (error) { - console.error("Error fetching users:", error); - res.status(500).json({ error: "Erreur lors de la récupération des utilisateurs" }); - } - }); - - // Route pour créer un nouvel utilisateur (réservée aux admins) - app.post("/api/users", requireAdmin, async (req, res) => { - try { - // Validation Zod - const createUserSchema = insertUserSchema.extend({ - username: insertUserSchema.shape.username.min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères"), - password: insertUserSchema.shape.password.min(4, "Le mot de passe doit contenir au moins 4 caractères"), - }); - - const validatedData = createUserSchema.parse(req.body); - - // Vérifier si l'utilisateur existe déjà - const existingUser = await storage.getUserByUsername(validatedData.username); - if (existingUser) { - return res.status(409).json({ error: "Ce nom d'utilisateur existe déjà" }); - } - - // Hasher le mot de passe - const hashedPassword = await bcrypt.hash(validatedData.password, 10); - - const newUser = await storage.createUser({ - username: validatedData.username, - password: hashedPassword, - role: validatedData.role || "user", - }); - - res.json({ - id: newUser.id, - username: newUser.username, - role: newUser.role, - }); - } catch (error: any) { - console.error("Error creating user:", error); - if (error.name === 'ZodError') { - return res.status(400).json({ error: error.errors[0]?.message || "Données invalides" }); - } - res.status(500).json({ error: "Erreur lors de la création de l'utilisateur" }); - } - }); - - // Route pour modifier un utilisateur (réservée aux admins) - app.patch("/api/users/:id", requireAdmin, async (req, res) => { - try { - const userId = req.params.id; - const { username, password, role } = req.body; - - // Vérifier si l'utilisateur existe - const existingUser = await storage.getUser(userId); - if (!existingUser) { - return res.status(404).json({ error: "Utilisateur non trouvé" }); - } - - const updateData: Partial = {}; - - if (username && username !== existingUser.username) { - // Vérifier si le nouveau username est déjà pris - const userWithSameUsername = await storage.getUserByUsername(username); - if (userWithSameUsername && userWithSameUsername.id !== userId) { - return res.status(409).json({ error: "Ce nom d'utilisateur est déjà utilisé" }); - } - updateData.username = username; - } - - if (password) { - // Hasher le nouveau mot de passe - updateData.password = await bcrypt.hash(password, 10); - } - - if (role && (role === "admin" || role === "user")) { - updateData.role = role; - } - - if (Object.keys(updateData).length === 0) { - return res.status(400).json({ error: "Aucune modification fournie" }); - } - - const updatedUser = await storage.updateUser(userId, updateData); - - res.json({ - id: updatedUser.id, - username: updatedUser.username, - role: updatedUser.role, - }); - } catch (error: any) { - console.error("Error updating user:", error); - res.status(500).json({ error: "Erreur lors de la modification de l'utilisateur" }); - } - }); - - // Route pour supprimer un utilisateur (réservée aux admins) - app.delete("/api/users/:id", requireAdmin, async (req, res) => { - try { - const userId = req.params.id; - const currentUser = req.user as User; - - // Empêcher l'admin de se supprimer lui-même - if (userId === currentUser.id) { - return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" }); - } - - // Vérifier si l'utilisateur existe - const existingUser = await storage.getUser(userId); - if (!existingUser) { - return res.status(404).json({ error: "Utilisateur non trouvé" }); - } - - await storage.deleteUser(userId); - - res.json({ success: true, message: "Utilisateur supprimé avec succès" }); - } catch (error: any) { - console.error("Error deleting user:", error); - res.status(500).json({ error: "Erreur lors de la suppression de l'utilisateur" }); - } - }); - - // Route pour obtenir les permissions d'un utilisateur (réservée aux admins) - app.get("/api/users/:id/page-permissions", requireAdmin, async (req, res) => { - try { - const userId = req.params.id; - const permissions = await storage.getUserPagePermissions(userId); - res.json(permissions); - } catch (error) { - console.error("Error fetching user permissions:", error); - res.status(500).json({ error: "Erreur lors de la récupération des permissions" }); - } - }); - - // Route pour mettre à jour les permissions d'un utilisateur (réservée aux admins) - app.post("/api/users/:id/page-permissions", requireAdmin, async (req, res) => { - try { - const userId = req.params.id; - const { pageIds } = req.body as { pageIds: string[] }; - - if (!Array.isArray(pageIds)) { - return res.status(400).json({ error: "pageIds doit être un tableau" }); - } - - // Supprimer toutes les permissions existantes de l'utilisateur - await storage.deleteAllUserPagePermissions(userId); - - // Créer les nouvelles permissions - const permissions = await Promise.all( - pageIds.map(pageId => - storage.createPagePermission({ userId, pageId }) - ) - ); - - res.json(permissions); - } catch (error) { - console.error("Error updating user permissions:", error); - res.status(500).json({ error: "Erreur lors de la mise à jour des permissions" }); - } - }); - - // Route pour migrer les permissions existantes (réservée aux admins) - app.post("/api/admin/migrate-permissions", requireAdmin, async (req, res) => { - try { - // Récupérer tous les utilisateurs - const allUsers = await storage.getAllUsers(); - let migratedCount = 0; - - for (const user of allUsers) { - // Récupérer toutes les pages créées par cet utilisateur - const userPages = await storage.getSocialPages(user.id); - - for (const page of userPages) { - // Vérifier si une permission existe déjà - const existingPermissions = await storage.getUserPagePermissions(user.id); - const hasPermission = existingPermissions.some(p => p.pageId === page.id); - - if (!hasPermission) { - // Créer la permission - await storage.createPagePermission({ userId: user.id, pageId: page.id }); - migratedCount++; - } - } - } - - res.json({ - success: true, - message: `${migratedCount} permissions migrées avec succès`, - migratedCount - }); - } catch (error) { - console.error("Error migrating permissions:", error); - res.status(500).json({ error: "Erreur lors de la migration des permissions" }); - } - }); - - // Route pour vérifier si le mot de passe admin par défaut a été changé - app.get("/api/auth/default-password-status", async (req, res) => { - try { - const adminUser = await storage.getUserByUsername("admin"); - - if (!adminUser) { - return res.json({ isDefault: false }); - } - - // Vérifier si le mot de passe correspond à "admin" - const isDefaultPassword = await bcrypt.compare("admin", adminUser.password); - - res.json({ isDefault: isDefaultPassword }); - } catch (error) { - console.error("Error checking default password status:", error); - res.status(500).json({ error: "Erreur lors de la vérification du statut du mot de passe" }); - } - }); - - // External API (clé API, pas de session) - app.use("/api/v1", externalRouter); - - // Paramètres de l'API externe (admin seulement) - app.get("/api/settings/external-api", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - res.json({ configured: !!(config?.externalApiKey) }); - } catch (error) { - console.error("Error fetching app config:", error); - res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); - } - }); - - app.post("/api/settings/external-api", requireAdmin, async (req, res) => { - try { - const { apiKey } = req.body; - if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { - return res.status(400).json({ error: "La clé API est requise" }); - } - await storage.upsertAppConfig({ externalApiKey: apiKey.trim() }); - res.json({ success: true, configured: true }); - } catch (error) { - console.error("Error saving app config:", error); - res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); - } - }); - - app.delete("/api/settings/external-api", requireAdmin, async (req, res) => { - try { - await storage.upsertAppConfig({ externalApiKey: null }); - res.json({ success: true, configured: false }); - } catch (error) { - console.error("Error deleting app config:", error); - res.status(500).json({ error: "Erreur lors de la suppression de la clé" }); - } - }); - - // Google Gemini API Key (TTS) - app.get("/api/settings/gemini", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - res.json({ configured: !!(config?.geminiApiKey) }); - } catch (error) { - console.error("Error fetching Gemini config:", error); - res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); - } - }); - - app.post("/api/settings/gemini", requireAdmin, async (req, res) => { - try { - const { apiKey } = req.body; - if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { - return res.status(400).json({ error: "La clé API est requise" }); - } - await storage.upsertAppConfig({ geminiApiKey: apiKey.trim() }); - res.json({ success: true, configured: true }); - } catch (error) { - console.error("Error saving Gemini config:", error); - res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); - } - }); - - app.delete("/api/settings/gemini", requireAdmin, async (req, res) => { - try { - await storage.upsertAppConfig({ geminiApiKey: null }); - res.json({ success: true, configured: false }); - } catch (error) { - console.error("Error deleting Gemini config:", error); - res.status(500).json({ error: "Erreur lors de la suppression de la clé" }); - } - }); - - // Qwen3-TTS : service local ou serveur GPU distant (ex. Unraid) - app.get("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - res.json({ url: config?.qwenTtsUrl ?? "", hasApiKey: !!config?.qwenTtsApiKey }); - } catch (error) { - console.error("Error fetching Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); - } - }); - - app.post("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - const url = normalizeQwenUrl(req.body?.url); - if (!url) { - return res.status(400).json({ error: "Adresse invalide (ex. http://192.168.1.20:8001)" }); - } - const { apiKey } = req.body; - await storage.upsertAppConfig({ - qwenTtsUrl: url, - // Clé laissée vide : on garde celle déjà enregistrée - ...(typeof apiKey === "string" && apiKey.trim() ? { qwenTtsApiKey: apiKey.trim() } : {}), - }); - res.json({ success: true }); - } catch (error) { - console.error("Error saving Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); - } - }); - - app.delete("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - await storage.upsertAppConfig({ qwenTtsUrl: null, qwenTtsApiKey: null }); - res.json({ success: true }); - } catch (error) { - console.error("Error deleting Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la suppression de la configuration" }); - } - }); - - // Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen) - app.post("/api/settings/qwen/test", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - const url = normalizeQwenUrl(req.body?.url) ?? config?.qwenTtsUrl ?? undefined; - const apiKey = (typeof req.body?.apiKey === "string" && req.body.apiKey.trim()) || config?.qwenTtsApiKey || undefined; - const { ffmpegService } = await import("./services/ffmpeg"); - const catalog = await ffmpegService.listVoices(url ? { url, apiKey } : undefined); - if (!catalog.qwen_available || !catalog.qwen_status) { - return res.json({ ok: false, error: "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }); - } - res.json({ ok: true, ...catalog.qwen_status, voices: catalog.qwen.length }); - } catch (error) { - console.error("Error testing Qwen config:", error); - res.json({ ok: false, error: error instanceof Error ? error.message : "Test impossible" }); - } - }); - - // Analytics Routes - app.use("/api/analytics", requireAuth, analyticsRouter); - - // Fichiers servis à la racine du domaine, sans authentification : vérification - // de propriété du domaine par TikTok ("URL properties"), Google, Meta… - // Monté ici pour être servi avant le catch-all du frontend, et sans dépendre - // d'une reconstruction du client. - app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), { - dotfiles: "ignore", - index: false, - })); - - // Pages légales publiques (exigées par TikTok pour l'audit de l'application) - app.use(legalRouter); - - // TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api - app.use("/api/tiktok", requireAuth, tiktokRouter); - app.use("/api/facebook", requireAuth, facebookRouter); - - // Reels & Music Routes - app.use("/api", requireAuth, reelsRouter); - - - // Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée - app.post("/api/sql/execute", requireAdmin, async (req, res) => { - // Vérification de sécurité désactivée pour permettre l'accès - // if (process.env.NODE_ENV === 'production' && process.env.ALLOW_SQL_CONSOLE !== 'true') { ... } - - try { - // Validation Zod - const sqlQuerySchema = z.object({ - query: z.string().min(1, "La requête SQL ne peut pas être vide"), - }); - - const validatedData = sqlQuerySchema.parse(req.body); - - const { db } = await import("./db"); - const result = await db.execute(validatedData.query); - - res.json({ - success: true, - result, - }); - } catch (error: any) { - console.error("Error executing SQL:", error); - if (error.name === 'ZodError') { - return res.status(400).json({ - success: false, - error: error.errors[0]?.message || "Données invalides", - }); - } - res.status(500).json({ - success: false, - error: error.message || "Erreur lors de l'exécution de la requête SQL", - }); - } - }); - - // Route pour obtenir la liste des tables (réservée aux admins) - app.get("/api/sql/tables", requireAdmin, async (req, res) => { - try { - const { db } = await import("./db"); - const result = await db.execute<{ tablename: string }>( - `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;` - ); - - res.json({ - tables: result.rows || result, - }); - } catch (error: any) { - console.error("Error fetching tables:", error); - res.status(500).json({ - error: error.message || "Erreur lors de la récupération des tables", - }); - } - }); - - - // Stats endpoint - app.get("/api/stats", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - - // Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts) - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); - - const today = new Date(yesterday); - today.setDate(today.getDate() + 1); - - const lastMonth = new Date(now); - lastMonth.setMonth(lastMonth.getMonth() - 1); - - const stats = await storage.getDashboardStats(userId, { - yesterdayStart: yesterday, - yesterdayEnd: today, - lastMonth, - }); - - const scheduledPosts = stats.scheduledPosts; - const scheduledPostsLastMonth = stats.scheduledPostsLastMonth; - const currentAiTexts = stats.aiTextsGenerated; - const aiTextsYesterday = stats.aiTextsYesterday; - - // Calculer les variations en pourcentage - const aiTextChange = aiTextsYesterday > 0 - ? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100) - : currentAiTexts > 0 ? 100 : 0; - - const scheduledPostsChange = scheduledPostsLastMonth > 0 - ? Math.round(((scheduledPosts - scheduledPostsLastMonth) / scheduledPostsLastMonth) * 100) - : scheduledPosts > 0 ? 100 : 0; - - res.json({ - scheduledPosts, - scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`, - scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down", - connectedPages: stats.connectedPages, - aiTextsGenerated: currentAiTexts, - aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`, - aiTextsTrending: aiTextChange >= 0 ? "up" : "down", - mediaStored: stats.mediaStored, - }); - } catch (error) { - console.error("Error fetching stats:", error); - res.status(500).json({ error: "Failed to fetch stats" }); - } - }); - - // Get available AI models from OpenRouter - app.get("/api/ai/models", requireAdmin, async (req, res) => { - try { - const models = await openRouterService.getAvailableModels(); - res.json({ models }); - } catch (error) { - console.error("Error fetching models:", error); - res.status(500).json({ error: "Failed to fetch models" }); - } - }); - - // AI text generation - app.post("/api/ai/generate", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { model, ...productInfo } = req.body; - - const generatedTexts = await openRouterService.generatePostText(productInfo, userId, model); - - // Save to database - await storage.createAiGeneration({ - userId, - productInfo, - generatedTexts, - }); - - res.json({ variants: generatedTexts }); - } catch (error) { - console.error("Error generating text:", error); - const { status, message } = describeGenerationError(error); - res.status(status).json({ error: message }); - } - }); - - // Media upload - app.post("/api/media/upload", requireAuth, upload.single("file"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } - - console.log(`📂 Processing upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(2)} MB)`); - - const user = req.user as User; - const userId = user.id; - - // Upload using file path (streamed from disk) - // req.file.path est disponible avec DiskStorage - const uploadResult = await cloudinaryService.uploadMedia( - req.file.path, - req.file.originalname, - userId, - req.file.mimetype - ); - - // Détection basique basée sur le mimetype ou l'extension - const isVideo = req.file.mimetype.startsWith("video/") || /\.(mp4|mov|avi|mkv|webm)$/i.test(req.file.originalname); - - // Vignette extraite tant que la vidéo est sur le disque : elle survit à la - // suppression du fichier après publication (historique illustré) - const thumbnailUrl = isVideo ? await createVideoThumbnail(req.file.path) : null; - - // Clean up temp file immediately after upload to free disk space - await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err)); - - const mediaItem = await storage.createMedia({ - userId, - type: isVideo ? "video" : "image", - thumbnailUrl, - cloudinaryPublicId: uploadResult.publicId, - originalUrl: uploadResult.originalUrl, - facebookFeedUrl: uploadResult.facebookFeedUrl, - instagramFeedUrl: uploadResult.instagramFeedUrl, - instagramStoryUrl: uploadResult.instagramStoryUrl, - fileName: req.file.originalname, - fileSize: req.file.size, - }); - - res.json(mediaItem); - } catch (error) { - console.error("Error uploading media:", error); - - // Attempt cleanup on error - if (req.file && req.file.path) { - await fs.promises.unlink(req.file.path).catch(() => { }); - } - - const errorMessage = error instanceof Error ? error.message : "Failed to upload media"; - res.status(500).json({ error: errorMessage }); - } - }); - - // One-time migration: fix absolute localhost URLs → relative paths in media table - app.post("/api/media/fix-urls", requireAuth, async (req, res) => { - try { - const user = req.user as User; - if ((user as any).role !== 'admin') { - return res.status(403).json({ error: "Admin only" }); - } - - // Convert any absolute URL like http(s)://hostname/uploads/... → /uploads/... - const result = await pool.query(` - UPDATE media SET - original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\\1'), - facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\\1'), - instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\\1'), - instagram_story_url= regexp_replace(instagram_story_url,'^https?://[^/]+(/uploads/)', '\\1') - WHERE - original_url ~ '^https?://[^/]+/uploads/' - OR facebook_feed_url ~ '^https?://[^/]+/uploads/' - OR instagram_feed_url ~ '^https?://[^/]+/uploads/' - OR instagram_story_url ~ '^https?://[^/]+/uploads/' - `); - - res.json({ updated: result.rowCount }); - } catch (error) { - console.error("Error fixing media URLs:", error); - res.status(500).json({ error: "Migration failed" }); - } - }); - - // Get media - app.get("/api/media", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - - // Sans paramètre, la médiathèque entière est renvoyée comme avant : les - // écrans qui doivent retrouver un média par son identifiant en dépendent. - const limit = parsePositiveInt(req.query.limit); - const offset = parsePositiveInt(req.query.offset); - const media = await storage.getMedia(userId, { limit, offset }); - - // Le total permet à l'appelant paginé de savoir s'il reste des pages — - // et à la médiathèque d'afficher un compteur juste sans tout charger. - if (limit !== undefined) { - res.setHeader("X-Total-Count", String(await storage.getMediaCount(userId))); - } - - res.json(media); - } catch (error) { - console.error("Error fetching media:", error); - res.status(500).json({ error: "Failed to fetch media" }); - } - }); - - // Apply overlays to image using Sharp (server-side processing) - // IMPORTANT: This must be BEFORE /api/media/:id to avoid being intercepted by :id parameter - app.post("/api/media/apply-overlays", requireAuth, async (req, res) => { - try { - const { imageUrl, ribbon, priceBadge, logo } = req.body; - const user = req.user as User; - const userId = user.id; - - if (!imageUrl) { - return res.status(400).json({ error: "Image URL required" }); - } - - console.log("🎨 Applying overlays server-side with Sharp..."); - console.log("📥 Image URL:", imageUrl); - console.log("🎀 Ribbon:", ribbon); - console.log("💰 Price Badge:", priceBadge); - console.log("🏢 Logo:", logo); - - // Download image from URL - const imageResponse = await fetch(imageUrl); - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - console.log("✅ Image downloaded:", imageBuffer.length, "bytes"); - - // Load with Sharp to process - const sharp = (await import("sharp")).default; - let image = sharp(imageBuffer).rotate(); - - // Get image metadata for dimensions - const metadata = await image.metadata(); - const width = metadata.width!; - const height = metadata.height!; - - console.log(`📐 Image dimensions: ${width}x${height}`); - - // Create SVG overlays - const overlays: any[] = []; - - // Add ribbon overlay - if (ribbon) { - const ribbonSize = 150; - const color = ribbon.color === 'red' ? '#dc2626' : '#eab308'; - const fontSize = ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12; - - // Position mapping for each corner - const positions: Record = { - north_west: { - x: 0, - y: 0, - polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle - textX: ribbonSize * 0.3, - textY: ribbonSize * 0.3, - textRotation: -45 - }, - north_east: { - x: width - ribbonSize, - y: 0, - polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle - textX: ribbonSize * 0.7, - textY: ribbonSize * 0.3, - textRotation: 45 - }, - south_west: { - x: 0, - y: height - ribbonSize, - polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle - textX: ribbonSize * 0.3, - textY: ribbonSize * 0.7, - textRotation: -135 - }, - south_east: { - x: width - ribbonSize, - y: height - ribbonSize, - polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle - textX: ribbonSize * 0.7, - textY: ribbonSize * 0.7, - textRotation: 135 - } - }; - - const pos = positions[ribbon.position] || positions.north_west; - - const ribbonSvg = ` - - - - ${ribbon.text} - - - `; - - overlays.push({ - input: Buffer.from(ribbonSvg), - top: pos.y, - left: pos.x - }); - } - - // Add price badge overlay - if (priceBadge) { - const padding = 16; - const badgeText = `${priceBadge.price} €`; - const fontSize = priceBadge.size; - const textWidth = badgeText.length * fontSize * 0.6; // Approximate - const badgeWidth = textWidth + padding * 2; - const badgeHeight = fontSize + padding; - const color = priceBadge.color === 'red' ? '#dc2626' : '#eab308'; - - let x = padding; - let y = padding; - - if (priceBadge.position === 'north_east') { - x = width - badgeWidth - padding; - } else if (priceBadge.position === 'south_west') { - y = height - badgeHeight - padding; - } else if (priceBadge.position === 'south_east') { - x = width - badgeWidth - padding; - y = height - badgeHeight - padding; - } - - const badgeSvg = ` - - - - ${badgeText} - - - `; - - overlays.push({ - input: Buffer.from(badgeSvg), - top: y, - left: x - }); - } - - // Add logo overlay - if (logo && logo.enabled) { - console.log("🏢 Adding logo overlay..."); - - // Get Cloudinary config to get logo public ID - const cloudinaryConfig = await storage.getCloudinaryConfig(); - if (cloudinaryConfig && cloudinaryConfig.logoPublicId) { - try { - // Build logo URL from Cloudinary - const logoUrl = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl); - console.log("📥 Downloading logo from:", logoUrl); - - // Download logo - const logoResponse = await fetch(logoUrl); - const logoBuffer = Buffer.from(await logoResponse.arrayBuffer()); - - // Determine logo size based on selection with safety cap - const padding = 20; - const maxLogoWidth = width - (padding * 2); // Ensure logo fits within canvas - - const logoSizePercentages = { - small: 0.35, // 35% of image width - medium: 0.50, // 50% of image width - large: 0.70 // 70% of image width - }; - - const requestedWidth = Math.round(width * logoSizePercentages[logo.size as keyof typeof logoSizePercentages] || logoSizePercentages.medium); - const logoWidth = Math.min(requestedWidth, maxLogoWidth); - - // Resize logo preserving aspect ratio and apply opacity - const resizedLogo = await sharp(logoBuffer) - .resize({ width: logoWidth, fit: 'contain' }) - .ensureAlpha() - .toBuffer(); - - // Get resized logo dimensions - const logoMetadata = await sharp(resizedLogo).metadata(); - const logoHeight = logoMetadata.height || logoWidth; - - // Ensure logo fits within height as well - const maxLogoHeight = height - (padding * 2); - if (logoHeight > maxLogoHeight) { - console.warn(`⚠️ Logo height ${logoHeight}px exceeds max ${maxLogoHeight}px, would be clipped`); - } - - // Calculate position with padding - let logoX = padding; - let logoY = padding; - - if (logo.position === 'north_east') { - logoX = width - logoWidth - padding; - } else if (logo.position === 'south_west') { - logoY = height - logoHeight - padding; - } else if (logo.position === 'south_east') { - logoX = width - logoWidth - padding; - logoY = height - logoHeight - padding; - } else if (logo.position === 'center') { - logoX = Math.round((width - logoWidth) / 2); - logoY = Math.round((height - logoHeight) / 2); - } - - // Apply opacity by manipulating alpha channel - let logoWithOpacity = resizedLogo; - if (logo.opacity < 100) { - // Create a semi-transparent version of the logo - const opacityFactor = logo.opacity / 100; - logoWithOpacity = await sharp(resizedLogo) - .ensureAlpha() - .linear(opacityFactor, 0) - .toBuffer(); - } - - // Add logo overlay - overlays.push({ - input: logoWithOpacity, - top: logoY, - left: logoX, - blend: 'over' - }); - - console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`); - } catch (logoError) { - console.error("⚠️ Failed to apply logo:", logoError); - // Continue without logo if it fails - } - } else { - console.log("⚠️ No logo configured in settings"); - } - } - - // Apply all overlays - if (overlays.length > 0) { - image = image.composite(overlays); - } - - // Convert to buffer - const outputBuffer = await image.jpeg({ quality: 95 }).toBuffer(); - - console.log("✅ Overlays applied, uploading to local storage..."); - - // Upload to local storage - const uploadResult = await cloudinaryService.uploadMedia( - outputBuffer, - `edited_${Date.now()}.jpg`, - userId, - 'image/jpeg' - ); - - // Save to database - // IMPORTANT: For edited images with overlays, use originalUrl for ALL formats - // because Cloudinary transformations would recreate versions WITHOUT the overlays - const mediaItem = await storage.createMedia({ - userId, - type: "image", - cloudinaryPublicId: uploadResult.publicId, - originalUrl: uploadResult.originalUrl, - facebookFeedUrl: uploadResult.originalUrl, // Use original with overlays - instagramFeedUrl: uploadResult.originalUrl, // Use original with overlays - instagramStoryUrl: uploadResult.originalUrl, // Use original with overlays - fileName: `edited_${Date.now()}.jpg`, - fileSize: outputBuffer.length, - }); - - console.log("✅ Image saved with ID:", mediaItem.id); - res.json(mediaItem); - } catch (error) { - console.error("💥 Error applying overlays:", error); - const errorMessage = error instanceof Error ? error.message : "Failed to apply overlays"; - res.status(500).json({ error: errorMessage }); - } - }); - - // Delete media - app.delete("/api/media/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const mediaId = req.params.id; - - // Get media to find cloudinary public ID - const allMedia = await storage.getMedia(userId); - const mediaToDelete = allMedia.find(m => m.id === mediaId); - - if (!mediaToDelete) { - return res.status(404).json({ error: "Media not found" }); - } - - // Delete from Cloudinary - if (mediaToDelete.cloudinaryPublicId) { - await cloudinaryService.deleteMedia( - mediaToDelete.cloudinaryPublicId, - userId, - mediaToDelete.type - ); - } - - // Delete from database - await storage.deleteMedia(mediaId); - - res.json({ success: true }); - } catch (error) { - console.error("Error deleting media:", error); - res.status(500).json({ error: "Failed to delete media" }); - } - }); - - // Audio Tracks Management (Admin Only) - Multi-file upload (stored locally) - app.post("/api/audio-tracks", requireAdmin, audioUpload.array("files", 20), async (req, res) => { - try { - const files = req.files as Express.Multer.File[]; - if (!files || files.length === 0) { - return res.status(400).json({ error: "No audio file uploaded" }); - } - - const user = req.user as User; - const results = []; - - for (const file of files) { - try { - console.log(`🎵 Audio saved locally: ${file.originalname} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); - - // Multer/busboy reçoit le nom en latin1, mais le navigateur l'envoie en UTF-8 → on reconvertit - const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8'); - const publicUrl = `/uploads/audio/${file.filename}`; - - let duration = 0; - try { - const meta = await musicMetadata.parseFile(file.path); - duration = Math.round(meta.format.duration ?? 0); - } catch { /* non-fatal */ } - - const track = await storage.createAudioTrack({ - userId: user.id, - title: decodedName.replace(/\.[^/.]+$/, ""), - fileName: decodedName, - url: publicUrl, - duration, - }); - - results.push({ success: true, track }); - } catch (fileError) { - // Remove the file if DB insert fails - await fs.promises.unlink(file.path).catch(() => { }); - results.push({ success: false, fileName: file.originalname, error: fileError instanceof Error ? fileError.message : "Save failed" }); - } - } - - res.json({ results }); - } catch (error) { - console.error("Error uploading audio tracks:", error); - if (req.files) { - for (const file of req.files as Express.Multer.File[]) { - await fs.promises.unlink(file.path).catch(() => { }); - } - } - const errorMessage = error instanceof Error ? error.message : "Failed to upload audio tracks"; - res.status(500).json({ error: errorMessage }); - } - }); - - // Route admin : correction des titres corrompus (latin1 lu comme UTF-8) - // Se déclenche aussi automatiquement au démarrage - const fixAudioTrackEncoding = async () => { - try { - const tracks = await storage.getAudioTracks(); - let fixed = 0; - for (const track of tracks) { - // Détecte les séquences typiques latin1/utf8 mal interprétées (ex: é, à , è) - if (/[\xC0-\xC3][\x80-\xBF]/.test(track.title)) { - const corrected = Buffer.from(track.title, 'latin1').toString('utf8'); - const correctedFileName = Buffer.from(track.fileName, 'latin1').toString('utf8'); - await pool.query( - `UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3`, - [corrected, correctedFileName, track.id] - ); - fixed++; - } - } - if (fixed > 0) console.log(`🎵 Fixed encoding for ${fixed} audio track(s).`); - } catch (e) { - console.error('Error fixing audio track encoding:', e); - } - }; - // Lancer la correction au démarrage - fixAudioTrackEncoding().catch(() => { }); - - app.post("/api/audio-tracks/fix-encoding", requireAdmin, async (req, res) => { - try { - await fixAudioTrackEncoding(); - const tracks = await storage.getAudioTracks(); - res.json({ success: true, tracks }); - } catch (error) { - res.status(500).json({ error: "Failed to fix encoding" }); - } - }); - - app.get("/api/audio-tracks", requireAuth, async (req, res) => { - try { - // Both admin and regular users can list audio tracks (to pick them for reels) - const tracks = await storage.getAudioTracks(); - res.json(tracks); - } catch (error) { - console.error("Error fetching audio tracks:", error); - res.status(500).json({ error: "Failed to fetch audio tracks" }); - } - }); - - app.delete("/api/audio-tracks/:id", requireAdmin, async (req, res) => { - try { - const { id } = req.params; - - const track = await storage.getAudioTrack(id); - if (!track) { - return res.status(404).json({ error: "Audio track not found" }); - } - - // Try to delete from MinIO if the URL matches the MinIO public URL pattern - const minioBase = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || '').replace(/\/$/, ''); - if (minioBase && track.url.startsWith(minioBase)) { - // URL = ${minioBase}/${bucket}/${objectKey} → extract objectKey - const withoutBase = track.url.slice(minioBase.length + 1); // strip "base/" - const objectKey = withoutBase.split('/').slice(1).join('/'); // strip bucket - if (objectKey) { - try { - await cloudinaryService.deleteMedia(objectKey, track.userId || '', 'video'); - } catch (minioError) { - console.warn(`Failed to delete audio from MinIO: ${objectKey}`, minioError); - } - } - } - - await storage.deleteAudioTrack(id); - res.json({ success: true }); - } catch (error) { - console.error("Error deleting audio track:", error); - res.status(500).json({ error: "Failed to delete audio track" }); - } - }); - - // Posts - app.get("/api/posts", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const posts = await storage.getPosts(userId); - res.json(posts); - } catch (error) { - console.error("Error fetching posts:", error); - res.status(500).json({ error: "Failed to fetch posts" }); - } - }); - - app.post("/api/posts", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body; - - // Convert mediaIds to standardized format: array of { mediaId, displayOrder } - let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; - - if (mediaIds && Array.isArray(mediaIds)) { - // New format: array of objects or strings - finalMediaItems = mediaIds.map((item: any, index: number) => { - if (typeof item === 'string') { - // Legacy array of strings: use index as displayOrder - return { mediaId: item, displayOrder: index }; - } else if (item.mediaId) { - // New format: object with mediaId and displayOrder - return { - mediaId: item.mediaId, - displayOrder: item.displayOrder ?? index, - }; - } - throw new Error("Invalid media format"); - }); - } else if (mediaId) { - // Legacy single mediaId - finalMediaItems = [{ mediaId, displayOrder: 0 }]; - } - - // Validate max 10 photos - if (finalMediaItems.length > 10) { - return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" }); - } - - // Validate that stories require media - if ((postType === 'story' || postType === 'both') && finalMediaItems.length === 0) { - return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" }); - } - - // Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok - if (postType === 'reel') { - if (finalMediaItems.length !== 1) { - return res.status(400).json({ error: "Un Reel nécessite exactement une vidéo" }); - } - const reelMedia = await storage.getMediaById(finalMediaItems[0].mediaId); - if (!reelMedia || reelMedia.type !== 'video' || (user.role !== 'admin' && reelMedia.userId !== userId)) { - return res.status(400).json({ error: "Le média d'un Reel doit être une de vos vidéos" }); - } - for (const pageId of Array.isArray(pageIds) ? pageIds : []) { - const page = await storage.getSocialPage(pageId); - if (!page || (page.platform !== 'facebook' && page.platform !== 'tiktok')) { - return res.status(400).json({ error: "Les Reels ne peuvent être publiés que sur Facebook ou TikTok" }); - } - } - } - - // Security: Verify user has access to all specified pages (unless admin) - if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) { - const accessiblePages = await storage.getUserAccessiblePages(userId); - const accessiblePageIds = accessiblePages.map(p => p.id); - - const hasAccessToAllPages = pageIds.every(pageId => - accessiblePageIds.includes(pageId) - ); - - if (!hasAccessToAllPages) { - return res.status(403).json({ - error: "Vous n'avez pas accès à certaines pages sélectionnées" - }); - } - } - - // Convert scheduledFor string to Date if provided - if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') { - postFields.scheduledFor = new Date(postFields.scheduledFor); - } - - // Set status to "scheduled" if scheduledFor is provided, otherwise "draft" - if (postFields.scheduledFor) { - postFields.status = "scheduled"; - - // Validate that scheduled posts require at least one page - if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) { - return res.status(400).json({ error: "Les posts programmés nécessitent au moins une page cible" }); - } - } - - // Create the post - const postData = insertPostSchema.parse({ ...postFields, userId }); - const post = await storage.createPost(postData); - - // Link media to post if provided (with display order) - if (finalMediaItems.length > 0) { - const postMediaValues = finalMediaItems.map(item => ({ - postId: post.id, - mediaId: item.mediaId, - displayOrder: item.displayOrder, - })); - await db.insert(postMedia).values(postMediaValues); - } - - // Create scheduled posts for each selected page - if (pageIds && Array.isArray(pageIds) && pageIds.length > 0) { - const scheduledAt = postFields.scheduledFor - ? new Date(postFields.scheduledFor) - : new Date(); // Publish immediately if no date specified - - const finalPostType = postType || 'feed'; - - for (const pageId of pageIds) { - // If postType is "both", create two separate scheduled posts (story + feed) - if (finalPostType === 'both') { - await storage.createScheduledPost({ - postId: post.id, - pageId, - postType: 'story', - scheduledAt, - }); - await storage.createScheduledPost({ - postId: post.id, - pageId, - postType: 'feed', - scheduledAt, - }); - } else { - await storage.createScheduledPost({ - postId: post.id, - pageId, - postType: finalPostType, - scheduledAt, - }); - // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas - if (finalPostType === 'reel' && alsoStory === true) { - const page = await storage.getSocialPage(pageId); - if (page?.platform === 'facebook') { - await storage.createScheduledPost({ - postId: post.id, - pageId, - postType: 'story', - scheduledAt, - }); - } - } - } - } - } - - res.json(post); - } catch (error) { - console.error("Error creating post:", error); - const errorMessage = error instanceof Error ? error.message : "Failed to create post"; - res.status(500).json({ error: errorMessage }); - } - }); - - app.get("/api/posts/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { id } = req.params; - - const postWithMedia = await storage.getPostWithMedia(id); - if (!postWithMedia) { - return res.status(404).json({ error: "Post not found" }); - } - - // Admin peut voir tous les posts - if (user.role === 'admin') { - return res.json(postWithMedia); - } - - // Propriétaire peut voir son propre post - if (postWithMedia.post.userId === userId) { - return res.json(postWithMedia); - } - - // Utilisateur standard peut voir les posts des pages qui lui sont assignées - const scheduledPostsForPost = await storage.getScheduledPostsByPost(id); - if (scheduledPostsForPost.length > 0) { - const accessiblePages = await storage.getUserAccessiblePages(userId); - const accessiblePageIds = accessiblePages.map(p => p.id); - - // Vérifier si au moins une page du post est accessible - const hasAccess = scheduledPostsForPost.some(sp => accessiblePageIds.includes(sp.pageId)); - if (hasAccess) { - return res.json(postWithMedia); - } - } - - return res.status(403).json({ error: "Unauthorized" }); - } catch (error) { - console.error("Error fetching post:", error); - res.status(500).json({ error: "Failed to fetch post" }); - } - }); - - app.patch("/api/posts/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { id } = req.params; - const { content } = req.body; - - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post not found" }); - } - - if (post.userId !== userId) { - return res.status(403).json({ error: "Unauthorized" }); - } - - const updatedPost = await storage.updatePost(id, { content }); - res.json(updatedPost); - } catch (error) { - console.error("Error updating post:", error); - res.status(500).json({ error: "Failed to update post" }); - } - }); - - app.patch("/api/posts/:id/media", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { id } = req.params; - const { mediaIds } = req.body; - - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post not found" }); - } - - if (post.userId !== userId) { - return res.status(403).json({ error: "Unauthorized" }); - } - - if (!Array.isArray(mediaIds)) { - return res.status(400).json({ error: "mediaIds must be an array" }); - } - - if (mediaIds.length > 10) { - return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" }); - } - - await storage.updatePostMedia(id, mediaIds); - const updatedPostWithMedia = await storage.getPostWithMedia(id); - res.json(updatedPostWithMedia); - } catch (error) { - console.error("Error updating post media:", error); - res.status(500).json({ error: "Failed to update post media" }); - } - }); - - // Agenda : ce qui est déjà prévu par jour et par magasin, Reels en montage compris. - // Sous /api/scheduled-posts pour être rafraîchi avec les publications planifiées. - app.get("/api/scheduled-posts/agenda", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const start = parseDateParam(req.query.startDate); - const end = parseDateParam(req.query.endDate); - - const pages = user.role === 'admin' - ? await storage.getAllSocialPages() - : await storage.getUserAccessiblePages(user.id); - if (pages.length === 0) return res.json([]); - - const pagesById = new Map(pages.map(p => [p.id, { - id: p.id, - pageName: p.pageName, - platform: p.platform, - avatarUrl: p.avatarUrl ?? null, - }])); - - const [scheduled, renderingJobs] = await Promise.all([ - storage.getScheduledPostsByPages(Array.from(pagesById.keys()), start, end), - storage.getRenderingReelJobs(), - ]); - - const items: AgendaItem[] = scheduled.map(agendaItemFromScheduledPost); - // Un rendu qui vient de finir peut avoir déjà créé ses publications. - const plannedPosts = new Set(items.map(i => i.postId)); - for (const job of renderingJobs) { - if (job.postId && plannedPosts.has(job.postId)) continue; - items.push(...agendaItemsFromReelJob(job, pagesById, start, end)); - } - - items.sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt)); - res.json(items); - } catch (error) { - console.error("Error fetching publication agenda:", error); - res.status(500).json({ error: "Failed to fetch publication agenda" }); - } - }); - - // Scheduled posts - app.get("/api/scheduled-posts", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { startDate, endDate } = req.query; - - // Une date illisible est ignorée plutôt que transmise telle quelle à SQL. - const start = parseDateParam(startDate); - const end = parseDateParam(endDate); - - let scheduledPosts; - - if (user.role === 'admin') { - // Admin voit tous les posts programmés - on récupère toutes les pages - const allPages = await storage.getAllSocialPages(); - const allPageIds = allPages.map(p => p.id); - - if (allPageIds.length > 0) { - scheduledPosts = await storage.getScheduledPostsByPages(allPageIds, start, end); - } else { - scheduledPosts = []; - } - } else { - // User voit uniquement les posts programmés sur les pages qui lui sont attribuées - const accessiblePages = await storage.getUserAccessiblePages(userId); - const accessiblePageIds = accessiblePages.map(p => p.id); - - if (accessiblePageIds.length > 0) { - scheduledPosts = await storage.getScheduledPostsByPages(accessiblePageIds, start, end); - } else { - scheduledPosts = []; - } - } - - res.json(scheduledPosts); - } catch (error) { - console.error("Error fetching scheduled posts:", error); - res.status(500).json({ error: "Failed to fetch scheduled posts" }); - } - }); - - app.delete("/api/scheduled-posts/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { id } = req.params; - - // Verify the scheduled post exists - const scheduledPost = await storage.getScheduledPost(id); - if (!scheduledPost) { - return res.status(404).json({ error: "Scheduled post not found" }); - } - - const post = await storage.getPost(scheduledPost.postId); - if (!post) { - return res.status(404).json({ error: "Post not found" }); - } - - // Admin peut tout supprimer, user peut supprimer uniquement ses propres posts - if (user.role !== 'admin' && post.userId !== userId) { - return res.status(403).json({ error: "Unauthorized" }); - } - - await storage.deleteScheduledPost(id); - res.json({ success: true }); - } catch (error) { - console.error("Error deleting scheduled post:", error); - res.status(500).json({ error: "Failed to delete scheduled post" }); - } - }); - - app.patch("/api/scheduled-posts/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const { id } = req.params; - - // Verify the scheduled post exists - const scheduledPost = await storage.getScheduledPost(id); - if (!scheduledPost) { - return res.status(404).json({ error: "Scheduled post not found" }); - } - - const post = await storage.getPost(scheduledPost.postId); - if (!post) { - return res.status(404).json({ error: "Post not found" }); - } - - // Admin peut tout modifier, user peut modifier uniquement ses propres posts - if (user.role !== 'admin' && post.userId !== userId) { - return res.status(403).json({ error: "Unauthorized" }); - } - - // Only allow updating scheduledAt and pageId - const { scheduledAt, pageId } = req.body; - const updateData: Partial = {}; - - if (scheduledAt) { - updateData.scheduledAt = new Date(scheduledAt); - // Synchroniser avec la table posts - await storage.updatePost(scheduledPost.postId, { scheduledFor: new Date(scheduledAt) }); - } - - if (pageId) { - updateData.pageId = pageId; - } - - const updated = await storage.updateScheduledPost(id, updateData); - res.json(updated); - } catch (error) { - console.error("Error updating scheduled post:", error); - res.status(500).json({ error: "Failed to update scheduled post" }); - } - }); - - app.post("/api/scheduled-posts", requireAuth, async (req, res) => { - try { - const scheduledPostData = insertScheduledPostSchema.parse(req.body); - - // If postType is "both", create two separate scheduled posts (story + feed) - // This prevents retry loops - each post type is independent - if (scheduledPostData.postType === 'both') { - const [storyPost, feedPost] = await Promise.all([ - storage.createScheduledPost({ - ...scheduledPostData, - postType: 'story' as const, - }), - storage.createScheduledPost({ - ...scheduledPostData, - postType: 'feed' as const, - }), - ]); - // Return array so frontend knows it's a split operation - res.json([storyPost, feedPost]); - } else { - const scheduledPost = await storage.createScheduledPost(scheduledPostData); - res.json(scheduledPost); - } - } catch (error) { - console.error("Error creating scheduled post:", error); - res.status(500).json({ error: "Failed to create scheduled post" }); - } - }); - - // Social pages - - /** - * Vue client d'une page : sans jetons, avec un indicateur de renouvellement - * automatique (vrai quand un jeton utilisateur longue durée est enregistré). - */ - function toClientPage(page: SocialPage) { - const { accessToken, userAccessToken, refreshToken, ...safe } = page; - return { - ...safe, - autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken, - }; - } - - app.get("/api/pages", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - - let pages: SocialPage[]; - - if (user.role === 'admin') { - // Les admins voient toutes les pages de tous les utilisateurs - pages = await storage.getAllSocialPages(); - } else { - // Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès - pages = await storage.getUserAccessiblePages(userId); - } - - // Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que - // l'information dont l'UI a besoin, dont la capacité à se renouveler seule. - res.json(pages.map(toClientPage)); - } catch (error) { - console.error("Error fetching pages:", error); - res.status(500).json({ error: "Failed to fetch pages" }); - } - }); - - app.post("/api/pages", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - - // TikTok n'autorise pas la saisie manuelle d'un token : le compte doit - // passer par le flux OAuth (/api/tiktok/connect). - if (req.body?.platform === 'tiktok') { - return res.status(400).json({ - error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.", - }); - } - - // Calculate token expiration date (60 days from now) - const tokenExpiresAt = new Date(); - tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); - - const pageData = insertSocialPageSchema.parse({ - ...req.body, - userId, - tokenExpiresAt - }); - const page = await storage.createSocialPage(pageData); - res.json(toClientPage(page)); - } catch (error) { - console.error("Error creating page:", error); - res.status(500).json({ error: "Failed to create page" }); - } - }); - - app.put("/api/pages/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const pageId = req.params.id; - - const existingPage = await storage.getSocialPage(pageId); - if (!existingPage || existingPage.userId !== userId) { - return res.status(404).json({ error: "Page non trouvée" }); - } - - let parsedData = insertSocialPageSchema.partial().parse(req.body); - let pageData: Partial = { ...parsedData }; - - // If accessToken is being updated, recalculate expiration date (60 days from now) - // AND reset the status to valid so the UI updates immediately - // (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth) - if (parsedData.accessToken && existingPage.platform !== 'tiktok') { - const tokenExpiresAt = new Date(); - tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); - pageData = { - ...pageData, - tokenExpiresAt, - tokenStatus: 'valid', - lastTokenCheck: new Date() - }; - } - - const updatedPage = await storage.updateSocialPage(pageId, pageData); - res.json(toClientPage(updatedPage)); - } catch (error) { - console.error("Error updating page:", error); - res.status(500).json({ error: "Failed to update page" }); - } - }); - - app.delete("/api/pages/:id", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const pageId = req.params.id; - - const existingPage = await storage.getSocialPage(pageId); - if (!existingPage || existingPage.userId !== userId) { - return res.status(404).json({ error: "Page non trouvée" }); - } - - await storage.deleteSocialPage(pageId); - res.json({ success: true }); - } catch (error) { - console.error("Error deleting page:", error); - res.status(500).json({ error: "Failed to delete page" }); - } - }); - - // AI Generations - app.get("/api/ai/generations", requireAdmin, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const generations = await storage.getAiGenerations(userId); - res.json(generations); - } catch (error) { - console.error("Error fetching AI generations:", error); - res.status(500).json({ error: "Failed to fetch AI generations" }); - } - }); - - // Cloudinary Config - app.get("/api/cloudinary/config", requireAuth, async (req, res) => { - try { - // Configuration est maintenant globale, plus de "userId" - const config = await storage.getCloudinaryConfig(); - - if (!config) { - return res.json(null); - } - - // Don't send the API secret to the client - const { apiSecret, ...safeConfig } = config; - const logoUrl = config.logoPublicId ? buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl) : null; - res.json({ ...safeConfig, logoUrl }); - } catch (error) { - console.error("Error fetching Cloudinary config:", error); - res.status(500).json({ error: "Failed to fetch Cloudinary config" }); - } - }); - - app.post("/api/cloudinary/config", requireAdmin, async (req, res) => { - try { - // Check if config already exists globally - const existingConfig = await storage.getCloudinaryConfig(); - - let config; - if (existingConfig) { - // Pour les mises à jour, utiliser le schéma qui rend les secrets optionnels - const updateData = updateCloudinaryConfigSchema.parse(req.body); - - // Si apiKey/apiSecret ne sont pas fournis, garder les anciens - const finalData = { - ...updateData, - apiKey: updateData.apiKey || existingConfig.apiKey, - apiSecret: updateData.apiSecret || existingConfig.apiSecret, - }; - - config = await storage.updateCloudinaryConfig(finalData); - } else { - // Pour les créations, exiger tous les champs - const configData = insertCloudinaryConfigSchema.parse(req.body); - config = await storage.createCloudinaryConfig(configData); - } - - // Don't send the API secret back - const { apiSecret, ...safeConfig } = config; - res.json(safeConfig); - } catch (error) { - console.error("Error saving Cloudinary config:", error); - res.status(500).json({ error: "Failed to save Cloudinary config" }); - } - }); - - // Upload company logo - app.post("/api/cloudinary/logo", requireAdmin, upload.single("logo"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No logo file uploaded" }); - } - - // Get or auto-create config row (local storage doesn't need credentials) - let cloudinaryConfig = await storage.getCloudinaryConfig(); - if (!cloudinaryConfig) { - cloudinaryConfig = await storage.createCloudinaryConfig({ - cloudName: 'local', apiKey: 'local', apiSecret: 'local', - }); - } - - // Delete old logo if exists - if (cloudinaryConfig.logoPublicId) { - try { - await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId); - } catch (error) { - console.warn("Failed to delete old logo:", error); - // Continue anyway - not critical - } - } - - // Read from disk (multer uses diskStorage, not memoryStorage) - const logoBuffer = await fs.promises.readFile(req.file.path); - await fs.promises.unlink(req.file.path).catch(() => {}); - - const uploadResult = await cloudinaryService.uploadLogo( - logoBuffer, - req.file.originalname - ); - - // Update global config with new logo public ID - const updatedConfig = await storage.updateCloudinaryConfig({ - logoPublicId: uploadResult.publicId, - }); - - res.json({ - logoPublicId: uploadResult.publicId, - logoUrl: uploadResult.url, - }); - } catch (error) { - console.error("Error uploading logo:", error); - const errorMessage = error instanceof Error ? error.message : "Failed to upload logo"; - res.status(500).json({ error: errorMessage }); - } - }); - - // Delete company logo - app.delete("/api/cloudinary/logo", requireAdmin, async (req, res) => { - try { - const cloudinaryConfig = await storage.getCloudinaryConfig(); - if (!cloudinaryConfig || !cloudinaryConfig.logoPublicId) { - return res.status(404).json({ error: "No logo found" }); - } - - // Delete from Cloudinary - await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId); - - // Remove from global config - await storage.updateCloudinaryConfig({ - logoPublicId: null, - }); - - res.json({ success: true }); - } catch (error) { - console.error("Error deleting logo:", error); - const errorMessage = error instanceof Error ? error.message : "Failed to delete logo"; - res.status(500).json({ error: errorMessage }); - } - }); - - // OpenRouter models list - app.get("/api/openrouter/models", requireAuth, async (req, res) => { - try { - const response = await fetch("https://openrouter.ai/api/v1/models", { - headers: { - "Content-Type": "application/json", - } - }); - - if (!response.ok) { - throw new Error(`OpenRouter API error: ${response.status}`); - } - - const data = await response.json(); - res.json(data); - } catch (error) { - console.error("Error fetching OpenRouter models:", error); - res.status(500).json({ error: "Failed to fetch OpenRouter models" }); - } - }); - - // OpenRouter configuration - app.get("/api/openrouter/config", requireAdmin, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - const config = await storage.getOpenrouterConfig(userId); - - if (!config) { - return res.json(null); - } - - // Don't send the API key to the client - const { apiKey, ...safeConfig } = config; - res.json(safeConfig); - } catch (error) { - console.error("Error fetching OpenRouter config:", error); - res.status(500).json({ error: "Failed to fetch OpenRouter config" }); - } - }); - - app.post("/api/openrouter/config", requireAdmin, async (req, res) => { - try { - const user = req.user as User; - const userId = user.id; - - // Check if config already exists - const existingConfig = await storage.getOpenrouterConfig(userId); - - let config; - if (existingConfig) { - // Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel - const updateData = updateOpenrouterConfigSchema.parse({ - ...req.body, - userId, - }); - - // Si apiKey n'est pas fourni ou vide, garder l'ancien - const incomingKey = updateData.apiKey?.trim() || undefined; - const finalData = { - ...updateData, - apiKey: incomingKey || existingConfig.apiKey, - }; - - if (!finalData.apiKey) { - return res.status(400).json({ error: "Clé API OpenRouter manquante. Veuillez entrer une clé API valide." }); - } - - config = await storage.updateOpenrouterConfig(userId, finalData); - } else { - // Pour les créations, exiger tous les champs - const configData = insertOpenrouterConfigSchema.parse({ - ...req.body, - userId, - }); - config = await storage.createOpenrouterConfig(configData); - } - - // Don't send the API key back - const { apiKey, ...safeConfig } = config; - res.json(safeConfig); - } catch (error) { - console.error("Error saving OpenRouter config:", error); - res.status(500).json({ error: "Failed to save OpenRouter config" }); - } - }); - - // FFmpeg API Configuration - app.get("/api/ffmpeg/config", requireAdmin, async (req, res) => { - try { - const user = req.user as User; - // Lire depuis les variables d'environnement ou un storage dédié - // Pour simplifier, on renvoie juste si c'est configuré ou non - const apiUrl = process.env.FFMPEG_API_URL || ""; - - res.json({ - apiUrl: apiUrl, - configured: !!apiUrl && !!process.env.FFMPEG_API_KEY, - }); - } catch (error) { - console.error("Error fetching FFmpeg config:", error); - res.status(500).json({ error: "Failed to fetch FFmpeg config" }); - } - }); - - app.post("/api/ffmpeg/config", requireAdmin, async (req, res) => { - try { - const { apiUrl, apiKey } = req.body; - - if (!apiUrl) { - return res.status(400).json({ error: "URL de l'API FFmpeg requise" }); - } - - // Configurer le service FFmpeg avec les nouvelles valeurs - const { ffmpegService } = await import("./services/ffmpeg"); - - // Si apiKey fourni, configurer avec - if (apiKey) { - ffmpegService.configure(apiUrl, apiKey); - } else if (process.env.FFMPEG_API_KEY) { - // Garder la clé existante - ffmpegService.configure(apiUrl, process.env.FFMPEG_API_KEY); - } else { - return res.status(400).json({ error: "Clé API FFmpeg requise pour la première configuration" }); - } - - // Stocker dans les variables d'environnement (pour cette session) - process.env.FFMPEG_API_URL = apiUrl; - if (apiKey) { - process.env.FFMPEG_API_KEY = apiKey; - } - - // Tester la connexion - const isHealthy = await ffmpegService.healthCheck(); - - res.json({ - success: true, - apiUrl: apiUrl, - configured: true, - healthy: isHealthy, - }); - } catch (error) { - console.error("Error saving FFmpeg config:", error); - res.status(500).json({ error: "Failed to save FFmpeg config" }); - } - }); - - const httpServer = createServer(app); - - return httpServer; -} diff --git a/server/routes/analytics.ts b/server/routes/analytics.ts deleted file mode 100644 index b265108..0000000 --- a/server/routes/analytics.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Router } from 'express'; -import { db } from '../db'; -import { AnalyticsService } from '../services/analytics'; -import { TokenManager } from '../services/token_manager'; -import { postAnalytics, pageAnalyticsHistory, socialPages } from '../../shared/schema'; -import { eq, desc } from 'drizzle-orm'; - -const router = Router(); - -// Get Post Analytics -router.get('/posts/:postId', async (req, res) => { - try { - const { postId } = req.params; - const analytics = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - res.json(analytics || {}); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch post analytics' }); - } -}); - -// Refresh Post Analytics -router.post('/posts/:postId/refresh', async (req, res) => { - try { - const { postId } = req.params; - await AnalyticsService.syncPostAnalytics(postId); - const updated = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - res.json(updated); - } catch (error) { - res.status(500).json({ error: 'Failed to refresh post analytics' }); - } -}); - -// Get Page Analytics History -router.get('/pages/:pageId/history', async (req, res) => { - try { - const { pageId } = req.params; - const history = await db.query.pageAnalyticsHistory.findMany({ - where: eq(pageAnalyticsHistory.pageId, pageId), - orderBy: [desc(pageAnalyticsHistory.date)], - limit: 30 // Last 30 entries - }); - res.json(history); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch page history' }); - } -}); - -// Refresh Page Analytics -router.post('/pages/:pageId/refresh', async (req, res) => { - try { - const { pageId } = req.params; - await AnalyticsService.syncPageAnalytics(pageId); - - const page = await db.query.socialPages.findFirst({ - where: eq(socialPages.id, pageId) - }); - res.json(page); - } catch (error: any) { - console.error('Refresh error:', error); - res.status(500).json({ error: error.message || 'Failed to refresh page analytics' }); - } -}); - -// Manual Token Check Trigger -router.post('/tokens/check', async (req, res) => { - try { - // This runs in background, but we can await it for manual trigger - await TokenManager.checkAndRefreshTokens(); - res.json({ success: true, message: 'Token check completed' }); - } catch (error) { - res.status(500).json({ error: 'Token check failed' }); - } -}); - -export const analyticsRouter = router; diff --git a/server/routes/external.ts b/server/routes/external.ts deleted file mode 100644 index 44bdc69..0000000 --- a/server/routes/external.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { Router } from "express"; -import { z } from "zod"; -import path from "path"; -import { storage } from "../storage"; -import { db } from "../db"; -import { postMedia } from "@shared/schema"; -import { minioService } from "../services/minio"; -import { requireApiKey } from "../middleware/apiKey"; - -const router = Router(); - -router.use(requireApiKey); - -const publishSchema = z.object({ - content: z.string().min(1, "Le contenu est requis"), - imageUrl: z.string().url("URL d'image invalide").optional(), - pageIds: z.array(z.string()).min(1, "Au moins une page est requise"), - scheduledAt: z.string().datetime({ offset: true }).optional(), - postType: z.enum(["feed", "story", "both"]).default("feed"), - userId: z.string().optional(), -}); - -const IMAGE_MIME_TYPES: Record = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", -}; - -async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string; mimeType: string }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10_000); - - let response: Response; - try { - response = await fetch(url, { signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error(`Impossible de télécharger l'image (HTTP ${response.status})`); - } - - const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? ""; - const ext = IMAGE_MIME_TYPES[contentType]; - - if (!ext) { - throw new Error(`Type MIME non supporté: ${contentType}. Formats acceptés: JPEG, PNG, WebP, GIF`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - - const MAX_SIZE = 10 * 1024 * 1024; // 10 MB - if (buffer.length > MAX_SIZE) { - throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`); - } - - return { buffer, ext, mimeType: contentType }; -} - -/** - * POST /api/v1/publish - * - * Crée une publication (avec image optionnelle téléchargée depuis une URL) - * et la programme pour une ou plusieurs pages Facebook/Instagram. - * - * Headers: - * X-API-Key: - * - * Body: - * content string Texte de la publication - * imageUrl string? URL publique de l'image à télécharger - * pageIds string[] IDs des pages cibles (social_pages.id) - * scheduledAt ISO8601? Date/heure de publication (absent = immédiat) - * postType feed|story|both Type de publication (défaut: feed) - * userId string? ID utilisateur propriétaire (défaut: premier admin) - */ -router.post("/publish", async (req, res) => { - try { - const parsed = publishSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: "Données invalides", - details: parsed.error.flatten().fieldErrors, - }); - } - - const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data; - - // Résoudre l'utilisateur propriétaire - let ownerId = bodyUserId; - if (!ownerId) { - const users = await storage.getAllUsers(); - const admin = users.find((u) => u.role === "admin"); - if (!admin) { - return res.status(500).json({ error: "Aucun utilisateur admin trouvé" }); - } - ownerId = admin.id; - } else { - const user = await storage.getUser(ownerId); - if (!user) { - return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` }); - } - } - - // Vérifier que toutes les pages existent - const resolvedPages: Array<{ id: string; pageName: string }> = []; - for (const pageId of pageIds) { - const page = await storage.getSocialPage(pageId); - if (!page) { - return res.status(400).json({ error: `Page introuvable: ${pageId}` }); - } - resolvedPages.push({ id: page.id, pageName: page.pageName }); - } - - // Validation story → image obligatoire - if ((postType === "story" || postType === "both") && !imageUrl) { - return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" }); - } - - // Télécharger l'image si fournie - let mediaRecord: { id: string; originalUrl: string } | null = null; - if (imageUrl) { - const { buffer, ext, mimeType } = await downloadImage(imageUrl); - - const fileName = `external-${Date.now()}${ext}`; - const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType); - - const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image"; - mediaRecord = await storage.createMedia({ - userId: ownerId, - type: mediaType, - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - fileName, - fileSize: buffer.length, - }); - } - - // Créer le post - const scheduledFor = scheduledAt ? new Date(scheduledAt) : null; - - const post = await storage.createPost({ - userId: ownerId, - content, - status: scheduledFor ? "scheduled" : "scheduled", - scheduledFor: scheduledFor ?? new Date(), - aiGenerated: "false", - }); - - // Lier le média si présent - if (mediaRecord) { - await db.insert(postMedia).values({ - postId: post.id, - mediaId: mediaRecord.id, - displayOrder: 0, - }); - } - - // Créer les scheduled_posts par page - const scheduledAt_ = scheduledFor ?? new Date(); - const createdSchedules: Array<{ pageId: string; pageName: string; scheduledPostId: string }> = []; - - for (const page of resolvedPages) { - if (postType === "both") { - const story = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: "story", - scheduledAt: scheduledAt_, - }); - const feed = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: "feed", - scheduledAt: scheduledAt_, - }); - createdSchedules.push( - { pageId: page.id, pageName: page.pageName, scheduledPostId: story.id }, - { pageId: page.id, pageName: page.pageName, scheduledPostId: feed.id } - ); - } else { - const sp = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: postType, - scheduledAt: scheduledAt_, - }); - createdSchedules.push({ pageId: page.id, pageName: page.pageName, scheduledPostId: sp.id }); - } - } - - return res.status(201).json({ - success: true, - post: { - id: post.id, - content: post.content, - status: post.status, - scheduledAt: scheduledAt_, - postType, - pages: createdSchedules, - media: mediaRecord - ? { id: mediaRecord.id, url: mediaRecord.originalUrl } - : null, - }, - }); - } catch (error) { - console.error("[external API] Error:", error); - const message = error instanceof Error ? error.message : "Erreur interne"; - return res.status(500).json({ error: message }); - } -}); - -/** - * GET /api/v1/pages - * - * Liste toutes les pages disponibles (id + nom + plateforme). - * Utile pour connaître les pageIds à passer à /publish. - */ -router.get("/pages", async (_req, res) => { - try { - const users = await storage.getAllUsers(); - const admin = users.find((u) => u.role === "admin"); - if (!admin) { - return res.status(500).json({ error: "Aucun utilisateur admin trouvé" }); - } - - const pages = await storage.getSocialPages(admin.id); - return res.json( - pages.map((p) => ({ - id: p.id, - pageName: p.pageName, - platform: p.platform, - pageId: p.pageId, - tokenStatus: p.tokenStatus, - })) - ); - } catch (error) { - console.error("[external API] Error listing pages:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -/** - * GET /api/v1/posts - * - * Liste les publications planifiées (à venir). - * - * Query params: - * pageId string? Filtrer par page spécifique - * from ISO8601? Date de début (scheduledAt >= from) - * to ISO8601? Date de fin (scheduledAt <= to) - * status string? Statut du post (défaut: scheduled) - */ -router.get("/posts", async (req, res) => { - try { - const { pageId, from, to, status } = req.query as Record; - const postStatus = status || "scheduled"; - - // Récupérer toutes les pages (ou une seule si filtrée) - let pageIds: string[]; - if (pageId) { - const page = await storage.getSocialPage(pageId); - if (!page) { - return res.status(404).json({ error: `Page introuvable: ${pageId}` }); - } - pageIds = [pageId]; - } else { - pageIds = (await storage.getAllSocialPages()).map(p => p.id); - } - - if (pageIds.length === 0) { - return res.json([]); - } - - const startDate = from ? new Date(from) : undefined; - const endDate = to ? new Date(to) : undefined; - - const scheduledPosts = await storage.getScheduledPostsByPages(pageIds, startDate, endDate); - - // Grouper par postId, filtrer par statut et posts non publiés - const postMap = new Map; - schedules: Array<{ - id: string; - pageId: string; - pageName: string; - platform: string; - postType: string; - scheduledAt: Date; - }>; - }>(); - - for (const sp of scheduledPosts) { - // sp = { ...scheduled_posts, post: Post, page: SocialPage } - const post = sp.post; - if (!post) continue; - - // Ne montrer que les posts non publiés avec le bon statut - if (sp.publishedAt) continue; - if (post.status !== postStatus && postStatus !== "all") continue; - - if (!postMap.has(post.id)) { - // Récupérer les médias du post, en une requête plutôt qu'une par média - const postMediaLinks = await storage.getPostMedia(post.id); - const byId = new Map( - (await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId))) - .map(item => [item.id, item]) - ); - const mediaItems = postMediaLinks - .map(link => byId.get(link.mediaId)) - .filter((item): item is NonNullable => item !== undefined) - .map(item => ({ - id: item.id, - url: item.originalUrl, - type: item.type, - })); - - postMap.set(post.id, { - id: post.id, - content: post.content, - status: post.status, - scheduledFor: post.scheduledFor, - createdAt: post.createdAt, - media: mediaItems, - schedules: [], - }); - } - - const page = sp.page; - postMap.get(post.id)!.schedules.push({ - id: sp.id, - pageId: sp.pageId, - pageName: page?.pageName || "Inconnu", - platform: page?.platform || "facebook", - postType: sp.postType, - scheduledAt: sp.scheduledAt, - }); - } - - return res.json(Array.from(postMap.values())); - } catch (error) { - console.error("[external API] GET /posts error:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -/** - * PATCH /api/v1/posts/:id - * - * Modifier une publication planifiée. - * - * Body: - * content string? Nouveau texte - * scheduledAt ISO8601? Nouvelle date/heure de publication - * imageUrl string? URL d'une nouvelle image (remplace l'existante) - */ -router.patch("/posts/:id", async (req, res) => { - try { - const { id } = req.params; - const { content, scheduledAt, imageUrl } = req.body; - - // Vérifier que le post existe et est modifiable - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post introuvable" }); - } - if (post.status === "published") { - return res.status(400).json({ error: "Impossible de modifier un post déjà publié" }); - } - - // Mettre à jour le contenu si fourni - if (content !== undefined) { - await storage.updatePost(id, { content }); - } - - // Mettre à jour la date de planification si fournie - if (scheduledAt !== undefined) { - const newDate = new Date(scheduledAt); - await storage.updatePost(id, { scheduledFor: newDate }); - - // Propager à tous les scheduled_posts du post - const scheduledPosts = await storage.getScheduledPostsByPost(id); - for (const sp of scheduledPosts) { - if (!sp.publishedAt) { - await storage.updateScheduledPost(sp.id, { scheduledAt: newDate }); - } - } - } - - // Remplacer le média si une nouvelle imageUrl est fournie - if (imageUrl !== undefined) { - // Résoudre le userId pour le stockage du média - const ownerId = post.userId; - const { buffer, ext, mimeType } = await downloadImage(imageUrl); - const fileName = `external-edit-${Date.now()}${ext}`; - const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType); - const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image"; - const mediaRecord = await storage.createMedia({ - userId: ownerId, - type: mediaType, - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - fileName, - fileSize: buffer.length, - }); - - // Remplacer les médias existants par le nouveau - await storage.updatePostMedia(id, [mediaRecord.id]); - } - - // Retourner le post mis à jour - const updatedPost = await storage.getPost(id); - const postMediaLinks = await storage.getPostMedia(id); - const mediaItems: Array<{ id: string; url: string; type: string }> = []; - for (const link of postMediaLinks) { - const mediaItem = await storage.getMediaById(link.mediaId); - if (mediaItem) { - mediaItems.push({ - id: mediaItem.id, - url: mediaItem.originalUrl, - type: mediaItem.type, - }); - } - } - - const scheduledPosts = await storage.getScheduledPostsByPost(id); - - return res.json({ - success: true, - post: { - id: updatedPost!.id, - content: updatedPost!.content, - status: updatedPost!.status, - scheduledFor: updatedPost!.scheduledFor, - createdAt: updatedPost!.createdAt, - media: mediaItems, - schedules: await Promise.all(scheduledPosts.filter(sp => !sp.publishedAt).map(async sp => { - const page = await storage.getSocialPage(sp.pageId); - return { - id: sp.id, - pageId: sp.pageId, - pageName: page?.pageName || "Inconnu", - platform: page?.platform || "facebook", - postType: sp.postType, - scheduledAt: sp.scheduledAt, - }; - })), - }, - }); - } catch (error) { - console.error("[external API] PATCH /posts/:id error:", error); - const message = error instanceof Error ? error.message : "Erreur interne"; - return res.status(500).json({ error: message }); - } -}); - -/** - * DELETE /api/v1/posts/:id - * - * Supprimer un post planifié et toutes ses planifications associées. - * La suppression en cascade gère les scheduled_posts et post_media. - */ -router.delete("/posts/:id", async (req, res) => { - try { - const { id } = req.params; - - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post introuvable" }); - } - if (post.status === "published") { - return res.status(400).json({ error: "Impossible de supprimer un post déjà publié" }); - } - - await storage.deletePost(id); - - return res.json({ success: true, deleted: id }); - } catch (error) { - console.error("[external API] DELETE /posts/:id error:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -export { router as externalRouter }; diff --git a/server/routes/facebook.ts b/server/routes/facebook.ts deleted file mode 100644 index 2f4c742..0000000 --- a/server/routes/facebook.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Connexion et renouvellement des pages Facebook / Instagram. - * - * Une page connectée ici embarque le jeton utilisateur longue durée qui a servi - * à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de - * page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer - * tous les deux mois. - */ - -import crypto from 'crypto'; -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { facebookOAuthService } from '../services/facebook_oauth'; -import { TokenManager } from '../services/token_manager'; -import { encrypt } from '../utils/encryption'; -import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema'; - -export const facebookRouter = Router(); - -/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */ -const RETURN_PATH = '/pages'; - -function isAdmin(req: Request): boolean { - const user = req.user as User | undefined; - return user?.role === 'admin'; -} - -function buildReturnUrl(status: 'success' | 'error', message: string): string { - const params = new URLSearchParams({ facebook: status, message }); - return `${RETURN_PATH}?${params.toString()}`; -} - -/** - * État de la configuration Facebook. - * L'App Secret n'est jamais renvoyé au client. - */ -facebookRouter.get('/config', async (req: Request, res: Response) => { - try { - const config = await storage.getFacebookConfig(); - const configured = await facebookOAuthService.isConfigured(); - - // Tout le monde a besoin de savoir si la connexion est possible ; seuls les - // admins voient les détails de l'application développeur. - if (!isAdmin(req)) { - return res.json({ configured }); - } - - res.json({ - configured, - appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''), - hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET), - redirectUri: facebookOAuthService.getRedirectUri(req), - scopes: facebookOAuthService.getScopes().split(','), - }); - } catch (error) { - console.error('❌ [Facebook] Erreur lecture configuration:', error); - res.status(500).json({ error: 'Impossible de lire la configuration Facebook' }); - } -}); - -/** Enregistre les identifiants de l'application Facebook (admin). */ -facebookRouter.put('/config', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const existing = await storage.getFacebookConfig(); - // Le secret peut être omis lors d'une mise à jour : on garde l'existant - const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema; - const data = schema.parse(req.body); - - const config = await storage.upsertFacebookConfig(data); - - res.json({ - success: true, - appId: config.appId, - redirectUri: facebookOAuthService.getRedirectUri(req), - }); - } catch (error: any) { - console.error('❌ [Facebook] Erreur enregistrement configuration:', error); - res.status(400).json({ - error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide', - }); - } -}); - -/** - * Démarre la connexion des pages Facebook. - * À appeler par navigation complète du navigateur (window.location), pas en fetch. - */ -facebookRouter.get('/connect', async (req: Request, res: Response) => { - try { - if (!(await facebookOAuthService.isConfigured())) { - return res.redirect( - buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.") - ); - } - - // Protection CSRF : l'état est vérifié au retour - const state = crypto.randomBytes(16).toString('hex'); - (req.session as any).facebookOAuthState = state; - - const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req); - - // La session doit être écrite avant la redirection, sinon l'état est perdu - req.session.save((err) => { - if (err) { - console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err); - return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.')); - } - res.redirect(authUrl); - }); - } catch (error) { - console.error('❌ [Facebook] Erreur démarrage OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook') - ); - } -}); - -/** - * Retour d'autorisation Facebook : échange le code contre un jeton utilisateur - * longue durée, puis crée ou met à jour chaque page administrée. - */ -facebookRouter.get('/callback', async (req: Request, res: Response) => { - const user = req.user as User; - const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query; - - try { - if (oauthError) { - const description = (oauthErrorDescription as string) || (oauthError as string); - return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`)); - } - - const expectedState = (req.session as any)?.facebookOAuthState; - delete (req.session as any)?.facebookOAuthState; - - if (!state || !expectedState || state !== expectedState) { - return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).')); - } - - if (!code || typeof code !== 'string') { - return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook.")); - } - - const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req); - const targets = await facebookOAuthService.listPages(userToken.accessToken); - - if (targets.length === 0) { - return res.redirect( - buildReturnUrl( - 'error', - "Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page." - ) - ); - } - - // Le jeton utilisateur est la clé du renouvellement automatique : il est - // conservé chiffré, sur chaque page issue de cette autorisation. - const encryptedUserToken = encrypt(userToken.accessToken); - const existingPages = await storage.getSocialPages(user.id); - - let created = 0; - let updated = 0; - - for (const target of targets) { - const existing = existingPages.find( - (page) => page.platform === 'facebook' && page.pageId === target.id - ); - - if (existing) { - await storage.updateSocialPage(existing.id, { - pageName: target.name, - accessToken: target.accessToken, - // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas. - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - followersCount: target.followersCount, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - isActive: 'true', - }); - updated++; - continue; - } - - const page = await storage.createSocialPage({ - userId: user.id, - platform: 'facebook', - pageId: target.id, - pageName: target.name, - accessToken: target.accessToken, - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - followersCount: target.followersCount, - isActive: 'true', - }); - created++; - - // Les non-admins ne voient que les pages qui leur sont explicitement - // attribuées : sans cette permission, ils ne verraient pas leur propre page. - if (user.role !== 'admin') { - try { - await storage.createPagePermission({ userId: user.id, pageId: page.id }); - } catch (permissionError) { - console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError); - } - } - } - - // Un compte Instagram professionnel publie avec le jeton de la page Facebook - // à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il - // resterait sur un jeton saisi à la main, condamné à expirer. - for (const page of existingPages) { - if (page.platform !== 'instagram') continue; - const linked = targets.find((target) => target.instagram?.id === page.pageId); - if (!linked) continue; - - await storage.updateSocialPage(page.id, { - accessToken: linked.accessToken, - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - }); - updated++; - } - - const summary = [ - created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null, - updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null, - ] - .filter(Boolean) - .join(', '); - - console.log(`✅ [Facebook] ${summary} pour ${user.username}`); - res.redirect( - buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`) - ); - } catch (error) { - console.error('❌ [Facebook] Erreur callback OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible') - ); - } -}); - -/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */ -facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const page = await storage.getSocialPage(req.params.id); - - if (!page) { - return res.status(404).json({ error: 'Page non trouvée' }); - } - - if (user.role !== 'admin' && page.userId !== user.id) { - const accessible = await storage.getUserAccessiblePages(user.id); - if (!accessible.some((p) => p.id === page.id)) { - return res.status(403).json({ error: 'Accès refusé à cette page' }); - } - } - - const result = await TokenManager.checkPage(page.id); - res.json(result); - } catch (error) { - console.error('❌ [Facebook] Erreur renouvellement du jeton:', error); - res.status(502).json({ - error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton', - }); - } -}); - -/** Relance le contrôle de toutes les pages (admin). */ -facebookRouter.post('/tokens/check', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const results = await TokenManager.checkAndRefreshTokens(); - res.json({ - checked: results.length, - renewed: results.filter((r) => r.renewed).length, - failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length, - results, - }); - } catch (error) { - console.error('❌ [Facebook] Erreur contrôle global des jetons:', error); - res.status(500).json({ error: 'Contrôle des jetons impossible' }); - } -}); diff --git a/server/routes/legal.ts b/server/routes/legal.ts deleted file mode 100644 index 0a77948..0000000 --- a/server/routes/legal.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Pages légales publiques (conditions d'utilisation et politique de confidentialité). - * - * TikTok exige une URL publique pour chacune lors de la création de l'application - * développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont - * servies en HTML depuis le serveur, sans authentification, pour rester lisibles - * même par un robot qui n'exécute pas le JavaScript du client. - * - * Les mentions de l'exploitant sont préremplies avec l'identité de la société - * exploitante et restent surchargeables par variables d'environnement : - * LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM, - * LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL - */ - -import { Router, Request, Response } from 'express'; - -export const legalRouter = Router(); - -function getCompanyInfo() { - return { - name: process.env.LEGAL_COMPANY_NAME || 'FROUARD DISTRIBUTION', - tradeName: process.env.LEGAL_TRADE_NAME || "LA FOIR'FOUILLE", - legalForm: process.env.LEGAL_COMPANY_LEGAL_FORM - || 'Société par actions simplifiée au capital de 50 000 €', - rcs: process.env.LEGAL_COMPANY_RCS || '478 693 518 R.C.S. Nancy', - address: process.env.LEGAL_COMPANY_ADDRESS || '13 rue du Saule Gaillard, 54390 Frouard', - // Aucune adresse de contact ne figure au registre : elle doit être fournie, - // le RGPD imposant un point de contact pour l'exercice des droits. - email: process.env.LEGAL_CONTACT_EMAIL || '[Adresse e-mail de contact à renseigner]', - }; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function renderPage(title: string, body: string): string { - return ` - - - - - ${escapeHtml(title)} - - - -${body} - -`; -} - -function renderFooter(): string { - const company = getCompanyInfo(); - return `
-

- ${escapeHtml(company.name)} – enseigne ${escapeHtml(company.tradeName)}
- ${escapeHtml(company.legalForm)}
- ${escapeHtml(company.address)}
- ${escapeHtml(company.rcs)}
- Contact : ${escapeHtml(company.email)} -

-

Conditions d'utilisation · Politique de confidentialité

-
`; -} - -legalRouter.get('/terms', (_req: Request, res: Response) => { - const company = getCompanyInfo(); - - const body = ` -

Conditions d'utilisation

-

Dernière mise à jour : 11 août 2026

- -

- Cette application est un outil interne de gestion des réseaux sociaux exploité par - ${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé - ${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le - magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes - de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes - et pages officiels de nos magasins. -

- -

1. Accès au service

-

- L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte - nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des - actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment. -

- -

2. Comptes de réseaux sociaux connectés

-

- L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous - sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des - droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis - l'application, ou par le retrait de l'autorisation directement dans les réglages de la - plateforme concernée. -

- -

3. Contenus publiés

-

- Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus - doivent respecter la loi applicable ainsi que les règles des plateformes de destination, - notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta. - Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits, - y compris pour les images et les musiques utilisées. -

- -

4. Disponibilité

-

- Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication, - hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son - fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective - d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles. -

- -

5. Responsabilité

-

- Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des - dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une - publication manquée ou la suspension d'un compte par une plateforme tierce. -

- -

6. Modification des conditions

-

- Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page. -

- -

7. Contact

-

Pour toute question relative à ces conditions : ${escapeHtml(company.email)}

- - ${renderFooter()}`; - - res.type('html').send(renderPage("Conditions d'utilisation", body)); -}); - -legalRouter.get('/privacy', (_req: Request, res: Response) => { - const company = getCompanyInfo(); - - const body = ` -

Politique de confidentialité

-

Dernière mise à jour : 11 août 2026

- -

- Cette politique décrit les données traitées par notre outil interne de gestion des réseaux - sociaux. Le responsable de traitement est ${escapeHtml(company.name)}, - ${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)}, - immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne - ${escapeHtml(company.tradeName)}. -

- -

1. Données de nos utilisateurs internes

-

- Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion, - un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a - accès. Ces données servent uniquement à l'authentification et à la gestion des droits. -

- -

2. Données issues des comptes de réseaux sociaux connectés

-

- Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du - titulaire du compte et dans le cadre du périmètre user.info.basic : -

-
    -
  • l'identifiant technique du compte (open_id) ;
  • -
  • le nom d'affichage du compte ;
  • -
  • l'image de profil du compte ;
  • -
  • un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.
  • -
-

- Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni - la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces - comptes. -

- -

3. Finalités

-

- Ces données sont utilisées exclusivement pour identifier le compte de destination dans - l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à - l'heure programmée. Elles ne servent à aucune autre finalité. -

- -

4. Données que nous transmettons aux plateformes

-

- Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image, - le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de - confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la - politique de confidentialité de la plateforme concernée. -

- -

5. Conservation et sécurité

-
    -
  • Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).
  • -
  • - Ils sont conservés tant que le compte reste connecté à l'application, et supprimés - immédiatement lorsque le compte en est retiré. -
  • -
  • Les médias sources sont supprimés automatiquement après leur publication et leur péremption.
  • -
  • L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.
  • -
- -

6. Partage avec des tiers

-

- Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins - publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination - (TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos - prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions. -

- -

7. Retirer une autorisation

-

- Le titulaire d'un compte connecté peut retirer son autorisation à tout moment : -

-
    -
  • en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;
  • -
  • - ou directement dans TikTok, via Profil › Paramètres et confidentialité › - Sécurité et autorisations › Gérer les autorisations des applications. -
  • -
-

- Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la - suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis - celle-ci. -

- -

8. Vos droits

-

- Conformément au Règlement général sur la protection des données, vous disposez d'un droit - d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous - concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces - droits, écrivez à ${escapeHtml(company.email)}. -

- -

9. Modification de cette politique

-

- Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page. -

- - ${renderFooter()}`; - - res.type('html').send(renderPage('Politique de confidentialité', body)); -}); diff --git a/server/routes/reels.ts b/server/routes/reels.ts deleted file mode 100644 index 851625a..0000000 --- a/server/routes/reels.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Routes API pour les Reels Facebook et la musique Jamendo - */ - -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg'; -import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel'; -import { srtText } from '@shared/srt'; -import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue'; -import { - qwenTargetFromConfig, - resolveGeminiApiKey, - resolveLogoPath, - resolveQwenTarget, - resolveStoreName, -} from '../services/reels/assets'; -import { configuredRenderer } from '../services/reels/videoPipeline'; -import { openRouterService, describeGenerationError } from '../services/openrouter'; - -import { estimateVoiceTiming } from '../services/ttsSync'; -/** Piste musicale telle qu'attendue par le client. */ -interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; - previewUrl: string; - downloadUrl: string; - imageUrl: string; - license: string; -} - -export const reelsRouter = Router(); - -// ============================================ -// ROUTES MUSIQUE JAMENDO -// ============================================ - -/** - * Recherche de musiques par durée - * GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10 - */ -reelsRouter.get('/music/search', async (req: Request, res: Response) => { - try { - const { - minDuration = '10', - maxDuration = '120', - genre, - limit = '10', - offset = '0', - search, - } = req.query; - - res.json({ - tracks: [], - pagination: { - offset: parseInt(offset as string), - limit: parseInt(limit as string), - hasMore: false, - }, - }); - } catch (error) { - console.error('❌ Error searching music:', error); - res.status(500).json({ error: 'Erreur lors de la recherche de musique' }); - } -}); - -/** - * Voir plus de musiques (pagination) - * GET /api/music/more?offset=10&minDuration=10&maxDuration=60 - */ -reelsRouter.get('/music/more', async (req: Request, res: Response) => { - try { - const { - minDuration = '10', - maxDuration = '120', - genre, - limit = '10', - offset = '0', - search, - } = req.query; - - res.json({ - tracks: [], - pagination: { - offset: parseInt(offset as string), - limit: parseInt(limit as string), - hasMore: false, - }, - }); - } catch (error) { - console.error('❌ Error loading more music:', error); - res.status(500).json({ error: 'Erreur lors du chargement des musiques' }); - } -}); - -/** - * Obtenir les tracks populaires - * GET /api/music/popular - */ -reelsRouter.get('/music/popular', async (req: Request, res: Response) => { - try { - res.json({ tracks: [] }); - } catch (error) { - console.error('❌ Error fetching popular music:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' }); - } -}); - -// ============================================ -// ROUTES FAVORIS MUSIQUE -// ============================================ - -/** - * Obtenir les favoris de l'utilisateur - * GET /api/music/favorites - */ -reelsRouter.get('/music/favorites', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const favorites = await storage.getMusicFavorites(user.id); - - // Convertir en format MusicTrack pour le client - const tracks: MusicTrack[] = favorites.map(f => ({ - id: f.trackId, - title: f.title, - artist: f.artist, - albumName: f.albumName || '', - duration: f.duration, - previewUrl: f.previewUrl, - downloadUrl: f.downloadUrl, - imageUrl: f.imageUrl || '', - license: f.license || '', - })); - - res.json({ tracks, isFavorites: true }); - } catch (error) { - console.error('❌ Error fetching music favorites:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des favoris' }); - } -}); - -/** - * Vérifier si un track est en favori - * GET /api/music/favorites/check/:trackId - */ -reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId } = req.params; - - const isFavorite = await storage.isMusicFavorite(user.id, trackId); - res.json({ isFavorite }); - } catch (error) { - console.error('❌ Error checking music favorite:', error); - res.status(500).json({ error: 'Erreur lors de la vérification du favori' }); - } -}); - -/** - * Ajouter un favori - * POST /api/music/favorites - */ -reelsRouter.post('/music/favorites', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body; - - if (!trackId || !title) { - return res.status(400).json({ error: 'trackId et title requis' }); - } - - // Vérifier si déjà en favori - const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId); - if (alreadyFavorite) { - return res.json({ success: true, message: 'Déjà en favori' }); - } - - await storage.addMusicFavorite({ - userId: user.id, - trackId, - title, - artist: artist || '', - albumName: albumName || null, - duration: duration || 0, - previewUrl: previewUrl || '', - downloadUrl: downloadUrl || '', - imageUrl: imageUrl || null, - license: license || null, - }); - - res.json({ success: true }); - } catch (error) { - console.error('❌ Error adding music favorite:', error); - res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' }); - } -}); - -/** - * Supprimer un favori - * DELETE /api/music/favorites/:trackId - */ -reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId } = req.params; - - await storage.removeMusicFavorite(user.id, trackId); - res.json({ success: true }); - } catch (error) { - console.error('❌ Error removing music favorite:', error); - res.status(500).json({ error: 'Erreur lors de la suppression du favori' }); - } -}); - -// ============================================ -// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular) -// ============================================ - -/** - * Détails d'un track - * GET /api/music/:trackId - */ -reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => { - try { - const { trackId } = req.params; - return res.status(404).json({ error: 'Musique non trouvée' }); - } catch (error) { - console.error('❌ Error fetching music details:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des détails' }); - } -}); - -// ============================================ -// ROUTES REELS -// ============================================ - -/** - * Générer des textes IA pour un Reel (utilise le même service que les posts) - * POST /api/reels/generate-text - */ -reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { productInfo, model } = req.body; - - if (!productInfo) { - return res.status(400).json({ error: 'Informations produit requises' }); - } - - // Convertir la chaîne de texte simple en objet ProductInfo - // Si l'utilisateur envoie une chaîne, on l'utilise comme description du produit - const productInfoObject = typeof productInfo === 'string' - ? { - name: productInfo.trim(), // Utiliser le texte comme nom - description: productInfo.trim(), // Et comme description - } - : productInfo; - - const generatedTexts = await openRouterService.generatePostText(productInfoObject, user.id, model); - - // Sauvegarder la génération - await storage.createAiGeneration({ - userId: user.id, - productInfo: productInfoObject, - generatedTexts, - }); - - res.json({ variants: generatedTexts }); - } catch (error) { - console.error('❌ Error generating Reel text:', error); - const { status, message } = describeGenerationError(error); - res.status(status).json({ error: message }); - } -}); - -/** - * Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local - * GET /api/reels/voices - */ -reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => { - try { - const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig())); - res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status }); - } catch (error) { - console.error('❌ Error listing voices:', error); - res.json({ qwenAvailable: false, qwen: [], qwenStatus: null }); - } -}); - -/** - * Prévisualiser la voix TTS - * POST /api/reels/tts-preview - */ -reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => { - const parsed = ttsPreviewSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' }); - } - const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data; - - try { - const preview = await ffmpegService.previewVoice(text, { - voice: ttsVoice, - engine: ttsEngine, - style: ttsStyle, - geminiApiKey: await resolveGeminiApiKey(ttsEngine), - qwen: await resolveQwenTarget(ttsEngine), - }); - res.json({ - success: true, - audioBase64: preview.audio.toString('base64'), - duration: preview.duration, - words: preview.words, - engine: preview.engine, - voice: preview.voice, - warnings: preview.warnings, - }); - } catch (error) { - console.error('❌ Error generating TTS preview:', error); - const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix'; - res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message }); - } -}); - -/** - * Calculer la synchronisation texte/voix TTS - * POST /api/reels/sync-info - */ -reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => { - try { - const { text } = req.body; - if (!text) { - return res.status(400).json({ error: 'Texte requis' }); - } - // Estimation locale : ne déclenche aucune synthèse (payante avec Gemini) - res.json(estimateVoiceTiming(text)); - } catch (error) { - console.error('❌ Error calculating sync:', error); - res.status(500).json({ error: 'Erreur de calcul de synchronisation' }); - } -}); - -/** - * Créer et publier un Reel : le rendu part dans la file persistante. - * POST /api/reels - */ -reelsRouter.post('/reels', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const parsed = videoReelParamsSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' }); - } - - const { srtCues } = parsed.data; - const params: VideoReelParams = { - ...parsed.data, - // Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres - overlayText: srtCues ? undefined : parsed.data.overlayText, - description: parsed.data.description || (srtCues ? srtText(srtCues) : undefined), - storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]), - }; - - const waiting = await countActiveReelJobs(); - - const post = await storage.createPost({ - userId: user.id, - content: params.description || params.overlayText || '', - aiGenerated: 'false', - status: params.scheduledFor ? 'scheduled' : 'draft', - scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined, - generationStatus: 'pending', - generationProgress: 0, - }); - - await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params }); - console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`); - - res.json({ - success: true, - postId: post.id, - queued: waiting > 0, - message: waiting > 0 - ? "File d'attente occupée. Votre vidéo sera traitée dès que possible." - : 'Traitement démarré en arrière-plan.', - results: [], - videoUrl: '', - }); - } catch (error) { - console.error('❌ Error initiating Reel:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel', - }); - } -}); - -/** - * Obtenir la configuration des services Reels - * GET /api/reels/config - */ -reelsRouter.get('/reels/config', async (req: Request, res: Response) => { - try { - const [ffmpegConfigured, logoUrl] = await Promise.all([ - ffmpegService.healthCheck().catch(() => false), - resolveLogoPath(), - ]); - - res.json({ - jamendo: { configured: false }, - freesound: { configured: false }, - ffmpeg: { configured: ffmpegConfigured }, - // Aperçu : logo affiché par le lecteur, moteur de rendu utilisé - logoUrl: logoUrl ?? null, - renderer: configuredRenderer(), - }); - } catch (error) { - console.error('❌ Error fetching Reels config:', error); - res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' }); - } -}); - -/** - * Reels en cours de génération - * GET /api/reels/ongoing - */ -reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const ongoing = await storage.getOngoingReelPosts(user.id); - res.json(ongoing); - } catch (error) { - console.error('❌ Error fetching ongoing reels:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' }); - } -}); - -/** - * Supprimer un Reel - * DELETE /api/reels/:id - */ -reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { id } = req.params; - - // Vérifier si le post existe - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: 'Reel non trouvé' }); - } - - // Vérifier les permissions (admin ou propriétaire) - if (user.role !== 'admin' && post.userId !== user.id) { - return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' }); - } - - // Supprimer le post (cascade supprimera scheduled_posts et liens media) - await storage.deletePost(id); - - res.json({ success: true, message: 'Reel supprimé avec succès' }); - } catch (error) { - console.error('❌ Error deleting Reel:', error); - res.status(500).json({ error: 'Erreur lors de la suppression du Reel' }); - } -}); diff --git a/server/routes/tiktok.ts b/server/routes/tiktok.ts deleted file mode 100644 index 2264354..0000000 --- a/server/routes/tiktok.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Routes de gestion des comptes TikTok. - * - * Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la - * saisie manuelle d'un token), puis vit dans `social_pages` comme une page - * Facebook : mêmes permissions, même planification, même calendrier. - */ - -import crypto from 'crypto'; -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { tiktokService } from '../services/tiktok'; -import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema'; - -export const tiktokRouter = Router(); - -/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */ -const RETURN_PATH = '/pages'; - -function isAdmin(req: Request): boolean { - const user = req.user as User | undefined; - return user?.role === 'admin'; -} - -function buildReturnUrl(status: 'success' | 'error', message: string): string { - const params = new URLSearchParams({ tiktok: status, message }); - return `${RETURN_PATH}?${params.toString()}`; -} - -/** - * État de la configuration TikTok. - * Le client secret n'est jamais renvoyé au client. - */ -tiktokRouter.get('/config', async (req: Request, res: Response) => { - try { - const config = await storage.getTiktokConfig(); - const configured = await tiktokService.isConfigured(); - - // Tout le monde a besoin de savoir si la connexion est possible ; seuls les - // admins voient les détails de l'application développeur. - if (!isAdmin(req)) { - return res.json({ configured }); - } - - res.json({ - configured, - clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''), - hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET), - redirectUri: tiktokService.getRedirectUri(req), - }); - } catch (error) { - console.error('❌ [TikTok] Erreur lecture configuration:', error); - res.status(500).json({ error: 'Impossible de lire la configuration TikTok' }); - } -}); - -/** Enregistre les identifiants de l'application développeur TikTok (admin). */ -tiktokRouter.put('/config', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const existing = await storage.getTiktokConfig(); - // Le secret peut être omis lors d'une mise à jour : on garde l'existant - const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema; - const data = schema.parse(req.body); - - const config = await storage.upsertTiktokConfig(data); - - res.json({ - success: true, - clientKey: config.clientKey, - redirectUri: tiktokService.getRedirectUri(req), - }); - } catch (error: any) { - console.error('❌ [TikTok] Erreur enregistrement configuration:', error); - res.status(400).json({ - error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide', - }); - } -}); - -/** - * Démarre la connexion d'un compte TikTok. - * À appeler par navigation complète du navigateur (window.location), pas en fetch. - */ -tiktokRouter.get('/connect', async (req: Request, res: Response) => { - try { - if (!(await tiktokService.isConfigured())) { - return res.redirect( - buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.") - ); - } - - // Protection CSRF : l'état est vérifié au retour - const state = crypto.randomBytes(16).toString('hex'); - (req.session as any).tiktokOAuthState = state; - - const authUrl = await tiktokService.buildAuthorizationUrl(state, req); - - // La session doit être écrite avant la redirection, sinon l'état est perdu - req.session.save((err) => { - if (err) { - console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err); - return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.')); - } - res.redirect(authUrl); - }); - } catch (error) { - console.error('❌ [TikTok] Erreur démarrage OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok') - ); - } -}); - -/** - * Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le - * compte dans social_pages. - */ -tiktokRouter.get('/callback', async (req: Request, res: Response) => { - const user = req.user as User; - const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query; - - try { - if (oauthError) { - const description = (oauthErrorDescription as string) || (oauthError as string); - return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`)); - } - - const expectedState = (req.session as any)?.tiktokOAuthState; - delete (req.session as any)?.tiktokOAuthState; - - if (!state || !expectedState || state !== expectedState) { - return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).')); - } - - if (!code || typeof code !== 'string') { - return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok.")); - } - - const tokens = await tiktokService.exchangeCodeForToken(code, req); - const profile = await tiktokService.getUserInfo(tokens.access_token); - const openId = profile.openId || tokens.open_id; - - // Un compte déjà connecté est mis à jour plutôt que dupliqué - const existingPages = await storage.getSocialPages(user.id); - const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId); - - if (existing) { - await storage.updateSocialPage(existing.id, { - pageName: profile.displayName, - avatarUrl: profile.avatarUrl || null, - isActive: 'true', - }); - await tiktokService.persistTokens(existing.id, tokens); - - console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`); - return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`)); - } - - const page = await storage.createSocialPage({ - userId: user.id, - platform: 'tiktok', - pageId: openId, - pageName: profile.displayName, - accessToken: tokens.access_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - refreshToken: tokens.refresh_token, - refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000), - scopes: tokens.scope, - avatarUrl: profile.avatarUrl || null, - isActive: 'true', - }); - - // Les non-admins ne voient que les pages qui leur sont explicitement - // attribuées : sans cette permission, ils ne verraient pas leur propre compte. - if (user.role !== 'admin') { - try { - await storage.createPagePermission({ userId: user.id, pageId: page.id }); - } catch (permissionError) { - console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError); - } - } - - console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`); - res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`)); - } catch (error) { - console.error('❌ [TikTok] Erreur callback OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible') - ); - } -}); - -/** - * Options réellement autorisées sur un compte (confidentialité, commentaires, - * duo, stitch, durée max). TikTok impose de les afficher avant publication. - */ -tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const page = await storage.getSocialPage(req.params.id); - - if (!page || page.platform !== 'tiktok') { - return res.status(404).json({ error: 'Compte TikTok non trouvé' }); - } - - if (user.role !== 'admin' && page.userId !== user.id) { - const accessible = await storage.getUserAccessiblePages(user.id); - if (!accessible.some(p => p.id === page.id)) { - return res.status(403).json({ error: 'Accès refusé à ce compte' }); - } - } - - const creatorInfo = await tiktokService.getCreatorInfo(page); - res.json(creatorInfo); - } catch (error) { - console.error('❌ [TikTok] Erreur récupération creator info:', error); - res.status(502).json({ - error: error instanceof Error ? error.message : 'Impossible de contacter TikTok', - }); - } -}); diff --git a/server/services/analytics.ts b/server/services/analytics.ts deleted file mode 100644 index cf8d1c6..0000000 --- a/server/services/analytics.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { db } from '../db'; -import { - posts, - scheduledPosts, - postAnalytics, - pageAnalyticsHistory, - socialPages, - tokenStatusEnum -} from '../../shared/schema'; -import { eq, and, isNotNull, desc, gte, lt } from 'drizzle-orm'; -import { GraphAPIClient } from '../utils/graph_client'; -import { TokenManager } from './token_manager'; - -export class AnalyticsService { - - /** - * Fetches latest metrics for a specific post from Facebook/Instagram - * and updates the local postAnalytics table. - * Handles aggregation if a single Post content was published to multiple destinations. - */ - static async syncPostAnalytics(postId: string) { - console.log(`[AnalyticsService] Syncing post ${postId}`); - - // Find all published instances of this post with an external ID - const publishedInstances = await db.query.scheduledPosts.findMany({ - where: and( - eq(scheduledPosts.postId, postId), - isNotNull(scheduledPosts.externalPostId) - ), - with: { - page: true - } - }); - - if (publishedInstances.length === 0) { - console.log(`[AnalyticsService] No published external posts found for ${postId}`); - return; - } - - let totalImpressions = 0; - let totalReach = 0; - let totalEngagement = 0; - let totalReactions = 0; - let totalComments = 0; - let totalShares = 0; - let totalClicks = 0; - const rawDataList: any[] = []; - - for (const instance of publishedInstances) { - if (!instance.externalPostId || !instance.page) continue; - // Les métriques TikTok ne sont pas exposées par la Graph API - if (instance.page.platform === 'tiktok') continue; - - try { - // We need the page access token - let accessToken = instance.page.accessToken; - // Decrypt if needed - if (accessToken.includes(':')) { - try { accessToken = TokenManager.decrypt(accessToken); } catch { } - } - - // Determine fields based on platform (assuming Facbeook for now as per context) - // providing a generic list of fields - const fields = 'insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)'; - - const result = await GraphAPIClient.get(instance.externalPostId, { - accessToken, - params: { fields } - }); - - rawDataList.push({ id: instance.externalPostId, data: result }); - - // Parse Metrics - // Note: Graph API structure for insights is complex (data array). - // For simplicity in this implementation, we will try to extract what we can. - // This is a simplified mapper. - - const insights = result.insights?.data || []; - const findMetric = (name: string) => { - const m = insights.find((i: any) => i.name === name); - return m ? (m.values[0]?.value || 0) : 0; - }; - - const impressions = findMetric('post_impressions'); - const reach = findMetric('post_impressions_unique'); - const engagement = findMetric('post_engaged_users'); - const clicks = findMetric('post_clicks'); // might be 'post_clicks_by_type' - - const reactionCount = result.reactions?.summary?.total_count || 0; - const commentCount = result.comments?.summary?.total_count || 0; - const shareCount = result.shares?.count || 0; - - totalImpressions += impressions; - totalReach += reach; - totalEngagement += engagement; - totalClicks += clicks; // Aggregation might be complex for unique values, but sum is okay for now - totalReactions += reactionCount; - totalComments += commentCount; - totalShares += shareCount; - - } catch (error) { - console.error(`[AnalyticsService] Failed to fetch for instance ${instance.id}:`, error); - } - } - - // Update DB - // Check if exists - const existing = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - - if (existing) { - await db.update(postAnalytics) - .set({ - fetchedAt: new Date(), - impressions: totalImpressions, - reach: totalReach, - engagement: totalEngagement, - reactions: totalReactions, - comments: totalComments, - shares: totalShares, - clicks: totalClicks, - rawData: rawDataList - }) - .where(eq(postAnalytics.id, existing.id)); - } else { - await db.insert(postAnalytics).values({ - postId, - fetchedAt: new Date(), - impressions: totalImpressions, - reach: totalReach, - engagement: totalEngagement, - reactions: totalReactions, - comments: totalComments, - shares: totalShares, - clicks: totalClicks, - rawData: rawDataList - }); - } - } - - /** - * Syncs page-level analytics (Followers, Reach, Engagement, Views) - * Each metric is fetched independently for maximum resilience. - */ - static async syncPageAnalytics(pageId: string) { - console.log(`[AnalyticsService] Syncing page ${pageId}`); - - const page = await db.query.socialPages.findFirst({ - where: eq(socialPages.id, pageId) - }); - - if (!page) return; - - let accessToken = page.accessToken; - if (accessToken.includes(':')) { - try { accessToken = TokenManager.decrypt(accessToken); } catch { } - } - - // 1. Fetch Basic Page Info (Followers/Fans) — Essential - // If this fails, the token is likely invalid → abort - let followers = page.followersCount || 0; - try { - const basicInfo = await GraphAPIClient.get(page.pageId, { - accessToken, - params: { fields: 'fan_count' } - }); - if (basicInfo.fan_count !== undefined) { - followers = basicInfo.fan_count; - } - } catch (error: any) { - console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error); - - const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined'; - console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`); - - // Auth errors → mark token as expired and abort - if (error.code === 190 || error.code === 463 || error.code === 467) { - console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`); - await db.update(socialPages) - .set({ - tokenStatus: "expired", - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, pageId)); - return; - } - - throw new Error(`Failed to fetch Basic Info: ${error.message || error}`); - } - - // Helper: fetch a single page insight metric safely - const fetchInsight = async (metricName: string): Promise => { - try { - const result = await GraphAPIClient.get( - `${page.pageId}/insights/${metricName}`, - { - accessToken, - params: { period: 'day' } - } - ); - const data = result.data || []; - if (data.length > 0) { - const values = data[0].values || []; - // Take the most recent value (last entry = yesterday) - return values.length > 0 - ? (values[values.length - 1]?.value || 0) - : 0; - } - return 0; - } catch (error: any) { - if (error.code === 100) { - console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`); - } else { - console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error); - } - return 0; - } - }; - - // 2. Fetch each insight independently (resilient) - const pageReach = await fetchInsight('page_impressions_unique'); - const pageEngagement = await fetchInsight('page_post_engagements'); - const pageViews = await fetchInsight('page_views_total'); - - console.log(`[AnalyticsService] Metrics: followers=${followers}, reach=${pageReach}, engagement=${pageEngagement}, views=${pageViews}`); - - // 3. Update Page — auto-heal token status - await db.update(socialPages) - .set({ - followersCount: followers, - tokenStatus: 'valid', - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, pageId)); - - // 4. Upsert History snapshot — one entry per day per page - const today = new Date(); - today.setHours(0, 0, 0, 0); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - const existingEntry = await db.query.pageAnalyticsHistory.findFirst({ - where: and( - eq(pageAnalyticsHistory.pageId, pageId), - gte(pageAnalyticsHistory.date, today), - lt(pageAnalyticsHistory.date, tomorrow) - ) - }); - - if (existingEntry) { - // Update today's existing entry with latest values - await db.update(pageAnalyticsHistory) - .set({ - followersCount: followers, - pageReach, - pageViews, - pageEngagement, - date: new Date(), - }) - .where(eq(pageAnalyticsHistory.id, existingEntry.id)); - } else { - // Insert new entry for today - await db.insert(pageAnalyticsHistory).values({ - pageId, - date: new Date(), - followersCount: followers, - pageReach, - pageViews, - pageEngagement, - }); - } - - console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach, ${pageEngagement} engagement, ${pageViews} views`); - } - - /** - * Syncs analytics for ALL active pages. - * Called by the CRON job. - */ - static async syncAllPages(): Promise { - console.log('[AnalyticsService] Starting sync for all pages...'); - // Les statistiques passent par la Graph API : les comptes TikTok en sont exclus - const allPages = (await db.query.socialPages.findMany()) - .filter(page => page.platform !== 'tiktok'); - - for (const page of allPages) { - try { - await this.syncPageAnalytics(page.id); - } catch (error: any) { - console.error(`[AnalyticsService] Failed to sync page ${page.id}:`, error.message); - } - } - - console.log(`[AnalyticsService] Sync complete for ${allPages.length} pages.`); - } -} diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts deleted file mode 100644 index 2764562..0000000 --- a/server/services/cloudinary.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { v2 as cloudinary } from 'cloudinary'; -import { storage } from '../storage'; - -class CloudinaryService { - async uploadMedia( - file: Buffer | string, - fileName: string, - userId: string, - mimeType: string - ): Promise<{ - publicId: string; - originalUrl: string; - facebookFeedUrl: string | null; - instagramFeedUrl: string | null; - instagramStoryUrl: string | null; - }> { - // Get any available Cloudinary config (shared across all users) - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - // Configure Cloudinary with user's credentials - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const isVideo = mimeType.startsWith('video/'); - - // Define eager transformations for images (generated during upload) - // Note: Eager transformations are not applied to videos in this flow to avoid timeouts or huge processing - const eagerTransformations = !isVideo ? [ - { width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders) - { width: 1080, height: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed - { width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story - ] : undefined; - - const uploadOptions = { - folder: 'social-flow', - public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, - resource_type: isVideo ? 'video' : 'image', - eager: eagerTransformations, - eager_async: false, // Generate transformations immediately - }; - - let uploadResult; - - if (typeof file === 'string') { - // Upload from file path (streamed by SDK) - uploadResult = await cloudinary.uploader.upload(file, { - ...uploadOptions, - resource_type: isVideo ? 'video' : 'image', // Type assertion for SDK - } as any); - } else { - // Upload from buffer (memory) - uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - uploadOptions as any, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - uploadStream.end(file); - }); - } - - const publicId = uploadResult.public_id; - const originalUrl = uploadResult.secure_url; - - // Extract eager transformation URLs (generated by Cloudinary during upload) - let facebookFeedUrl = null; - let instagramFeedUrl = null; - let instagramStoryUrl = null; - - if (!isVideo && uploadResult.eager && uploadResult.eager.length === 3) { - facebookFeedUrl = uploadResult.eager[0].secure_url; // Facebook Feed (1200x630) - instagramFeedUrl = uploadResult.eager[1].secure_url; // Instagram Feed (1080x1080) - instagramStoryUrl = uploadResult.eager[2].secure_url; // Instagram Story (1080x1920) - } - - return { - publicId, - originalUrl, - facebookFeedUrl, - instagramFeedUrl, - instagramStoryUrl, - }; - } - - async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise { - // Get any available Cloudinary config (shared across all users) - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - await cloudinary.uploader.destroy(publicId, { - resource_type: mediaType, - }); - } - - async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'social-flow/stories', - public_id: `story-${Date.now()}-${originalFileName.replace(/\.[^/.]+$/, '')}`, - resource_type: 'image', - }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - - uploadStream.end(buffer); - }); - - return uploadResult.secure_url; - } - - async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'social-flow/logos', - public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, - resource_type: 'image', - }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - - uploadStream.end(buffer); - }); - - return { - publicId: uploadResult.public_id, - url: uploadResult.secure_url, - }; - } - - async deleteLogo(publicId: string): Promise { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - await cloudinary.uploader.destroy(publicId, { - resource_type: 'image', - }); - } -} - -export const cloudinaryService = new CloudinaryService(); diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts deleted file mode 100644 index 5574309..0000000 --- a/server/services/emojiRenderer.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { parse } from 'twemoji-parser'; -import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas'; -import sharp from 'sharp'; - -interface EmojiSegment { - type: 'text' | 'emoji'; - content: string; - url?: string; -} - -/** - * Service to render text with emojis on canvas using Twemoji - * Converts emojis to images and renders them alongside text - */ -export class EmojiRenderer { - private emojiCache = new Map(); - - /** - * Parse text into segments of text and emojis - */ - private parseTextWithEmojis(text: string): EmojiSegment[] { - const emojis = parse(text); - if (emojis.length === 0) { - return [{ type: 'text', content: text }]; - } - - const segments: EmojiSegment[] = []; - let lastIndex = 0; - - for (const emoji of emojis) { - // Add text before emoji - if (emoji.indices[0] > lastIndex) { - const textContent = text.substring(lastIndex, emoji.indices[0]); - if (textContent) { - segments.push({ type: 'text', content: textContent }); - } - } - - // Add emoji - segments.push({ - type: 'emoji', - content: emoji.text, - url: emoji.url - }); - - lastIndex = emoji.indices[1]; - } - - // Add remaining text - if (lastIndex < text.length) { - const textContent = text.substring(lastIndex); - if (textContent) { - segments.push({ type: 'text', content: textContent }); - } - } - - return segments; - } - - /** - * Download and cache emoji image - */ - async getEmojiImage(url: string): Promise { - if (this.emojiCache.has(url)) { - return this.emojiCache.get(url)!; - } - - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch emoji: ${response.status} ${response.statusText}`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - - // Convert SVG to PNG using sharp because node-canvas struggles with SVGs lacking dimensions - const pngBuffer = await sharp(buffer).png().toBuffer(); - - this.emojiCache.set(url, pngBuffer); - return pngBuffer; - } catch (error) { - console.error(`Error downloading emoji from ${url}:`, error); - throw error; - } - } - - /** - * Measure the width of text with emojis - */ - async measureText(text: string, ctx: CanvasRenderingContext2D, fontSize: number): Promise { - const segments = this.parseTextWithEmojis(text); - let totalWidth = 0; - - for (const segment of segments) { - if (segment.type === 'text') { - totalWidth += ctx.measureText(segment.content).width; - } else { - // Emoji width is approximately equal to font size - totalWidth += fontSize; - } - } - - return totalWidth; - } - - /** - * Draw text with emojis on canvas - */ - async drawText( - text: string, - ctx: CanvasRenderingContext2D, - x: number, - y: number, - fontSize: number - ): Promise { - const segments = this.parseTextWithEmojis(text); - let currentX = x; - - for (const segment of segments) { - if (segment.type === 'text') { - ctx.fillText(segment.content, currentX, y); - currentX += ctx.measureText(segment.content).width; - } else if (segment.url) { - try { - const emojiBuffer = await this.getEmojiImage(segment.url); - const emojiImage = await loadImage(emojiBuffer); - - // Draw emoji slightly above baseline to align with text - const emojiSize = fontSize; - const emojiY = y - fontSize * 0.8; // Adjust vertical position - - ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); - currentX += emojiSize; - } catch (error) { - console.error('Error loading emoji:', error); - // Fallback: draw text representation - ctx.fillText(segment.content, currentX, y); - currentX += ctx.measureText(segment.content).width; - } - } - } - } - - /** - * Break a long word that exceeds maxWidth into chunks - */ - private async breakLongWord( - word: string, - ctx: CanvasRenderingContext2D, - maxWidth: number, - fontSize: number - ): Promise { - const chunks: string[] = []; - let currentChunk = ''; - - // Split word into individual characters (graphemes for emoji support) - const chars = Array.from(word); - - for (const char of chars) { - const testChunk = currentChunk + char; - const width = await this.measureText(testChunk, ctx, fontSize); - - if (width > maxWidth && currentChunk) { - chunks.push(currentChunk); - currentChunk = char; - } else { - currentChunk = testChunk; - } - } - - if (currentChunk) { - chunks.push(currentChunk); - } - - return chunks; - } - - /** - * Wrap text with emojis into multiple lines - * Handles long words/emoji sequences by breaking them when needed - */ - async wrapText( - text: string, - ctx: CanvasRenderingContext2D, - maxWidth: number, - fontSize: number - ): Promise { - const words = text.split(' '); - const lines: string[] = []; - let currentLine = ''; - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const testWidth = await this.measureText(testLine, ctx, fontSize); - - if (testWidth > maxWidth && currentLine) { - // Current line would exceed width, push it and start new line - lines.push(currentLine); - currentLine = word; - - // Check if the word itself is too long for a single line - const wordWidth = await this.measureText(word, ctx, fontSize); - if (wordWidth > maxWidth) { - // Word is too long, need to break it into chunks - const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } - } else if (testWidth > maxWidth && !currentLine) { - // First word is too long, need to break it - const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } else { - currentLine = testLine; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; - } - - /** - * Clear emoji cache - */ - clearCache(): void { - this.emojiCache.clear(); - } -} - -// Singleton instance -export const emojiRenderer = new EmojiRenderer(); diff --git a/server/services/facebook.ts b/server/services/facebook.ts deleted file mode 100644 index e9edc4e..0000000 --- a/server/services/facebook.ts +++ /dev/null @@ -1,840 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { storage } from '../storage'; -import type { Post, SocialPage, Media } from '@shared/schema'; -import { imageProcessor } from './imageProcessor'; -import { minioService, resolvePublicUrl } from './minio'; - -interface FacebookPhotoResponse { - id: string; - post_id: string; -} - -interface FacebookFeedResponse { - id: string; -} - -interface FacebookVideoResponse { - id: string; - post_id?: string; -} - -interface FacebookError { - error: { - message: string; - type: string; - code: number; - }; -} - -export class FacebookService { - private baseUrl = 'https://graph.facebook.com/v23.0'; - - async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise { - if (page.platform !== 'facebook') { - throw new Error('This service only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - // Handle different post types - // Note: "both" type should never reach here - new posts are split at creation - // If it does reach here (legacy data), throw an error - if (postType === 'both') { - throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.'); - } - - if (postType === 'story') { - // Publish as story - one story per media - if (mediaList.length === 0) { - throw new Error('Stories require media (photo or video)'); - } - - // Publish each media as a separate story - const storyIds: string[] = []; - for (const media of mediaList) { - const storyId = await this.publishStory(post, page, media); - storyIds.push(storyId); - } - - // Return all story IDs joined together - return storyIds.join(','); - } else if (postType === 'reel') { - // Publish as Reel - if (mediaList.length === 0) { - throw new Error('Reels require a video'); - } - - const videoMedia = mediaList.find(m => m.type === 'video'); - if (!videoMedia) { - throw new Error('Reels require a video media'); - } - - // Use public URL so getMediaBuffer can fetch via HTTP if local file is missing - const publicUrl = resolvePublicUrl(videoMedia.originalUrl); - const nodeBuffer = await this.getMediaBuffer(publicUrl); - return await this.publishVideoFromBuffer(page, nodeBuffer, post.content); - } else { - // Default to feed - const imageMedia = mediaList.filter(m => m.type === 'image'); - const videoMedia = mediaList.filter(m => m.type === 'video'); - - if (imageMedia.length > 1) { - // Multi-photo carousel (images only) - return await this.publishMultiPhotoPost(post, page, mediaList); - } else if (imageMedia.length === 1) { - // Single image (even if mixed with videos, prioritize the image) - return await this.publishPhotoPost(post, page, imageMedia[0]); - } else if (videoMedia.length >= 1) { - // Video post - use video endpoint - return await this.publishVideoPost(post, page, videoMedia[0]); - } else { - // Text only - return await this.publishTextPost(post, page); - } - } - } - - private async publishTextPost(post: Post, page: SocialPage): Promise { - const params = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - }); - - const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookFeedResponse; - return data.id; - } - - private async getMediaBuffer(originalUrl: string): Promise { - const localPath = originalUrl.startsWith('/') - ? path.join(process.cwd(), originalUrl) - : originalUrl; - - if (localPath.startsWith('http')) { - const response = await fetch(localPath); - if (!response.ok) throw new Error(`Failed to fetch media from ${localPath}`); - return Buffer.from(await response.arrayBuffer()); - } - - return fs.promises.readFile(localPath); - } - - private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise { - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - if (post.content) { - formData.append('message', post.content); - } - formData.append('source', blob, path.basename(media.originalUrl)); - - const url = `${this.baseUrl}/${page.pageId}/photos`; - - const response = await fetch(url, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookPhotoResponse; - // Return the post_id which is the full post identifier - return data.post_id || data.id; - } - - /** - * Generates a Facebook-compatible video URL with Cloudinary transformations - * Facebook requires: H.264 video codec, AAC audio codec, MP4 container - */ - private getFacebookCompatibleVideoUrl(originalUrl: string): string { - // Check if this is a Cloudinary URL - const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//; - const match = originalUrl.match(cloudinaryPattern); - - if (!match) { - // Not a Cloudinary URL, return as-is (may fail on Facebook) - console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl); - return originalUrl; - } - - const [fullMatch, cloudName] = match; - const videoPath = originalUrl.replace(fullMatch, ''); - - // Apply transformations for Facebook compatibility: - // - vc_h264: Video codec H.264 - // - ac_aac: Audio codec AAC - // - f_mp4: Force MP4 format - // - q_auto: Automatic quality optimization - const transformations = 'vc_h264,ac_aac,f_mp4,q_auto'; - - const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`; - - console.log('🎬 Transforming video for Facebook compatibility:'); - console.log(' Original:', originalUrl); - console.log(' Transformed:', transformedUrl); - - return transformedUrl; - } - - private async publishVideoPost(post: Post, page: SocialPage, media: Media): Promise { - // Transform video URL to ensure Facebook compatibility (H.264/AAC/MP4) - const videoUrl = this.getFacebookCompatibleVideoUrl(resolvePublicUrl(media.originalUrl)); - - const params = new URLSearchParams({ - access_token: page.accessToken!, - description: post.content, - file_url: videoUrl, - }); - - const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookVideoResponse; - // Return the post_id or id - return data.post_id || data.id; - } - - private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise { - // Validate that all media are images - const imageMedia = mediaList.filter(m => m.type === 'image'); - if (imageMedia.length === 0) { - throw new Error('Multi-photo posts require at least one image'); - } - - if (imageMedia.length !== mediaList.length) { - console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`); - } - - // Step 1: Upload all photos as unpublished and collect photo IDs - const photoIds: string[] = []; - - for (const media of imageMedia) { - if (!media.originalUrl) { - console.warn(`Skipping media ${media.id} - no valid URL found`); - continue; - } - - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - formData.append('published', 'false'); - formData.append('source', blob, path.basename(media.originalUrl)); - - const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; - - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json() as FacebookError; - throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`); - } - - const uploadData = await uploadResponse.json() as FacebookPhotoResponse; - photoIds.push(uploadData.id); - } - - if (photoIds.length === 0) { - throw new Error('No photos were successfully uploaded'); - } - - // Step 2: Create feed post with all attached photos - const feedParams = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - }); - - // Add each photo as attached_media - photoIds.forEach((photoId, index) => { - feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId })); - }); - - const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`; - - const feedResponse = await fetch(feedUrl, { - method: 'POST', - }); - - if (!feedResponse.ok) { - const error = await feedResponse.json() as FacebookError; - throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`); - } - - const feedData = await feedResponse.json() as FacebookFeedResponse; - return feedData.id; - } - - async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise { - if (page.platform !== 'facebook') { - throw new Error('This service only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - // Facebook requires Unix timestamp for scheduled posts - const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000); - const now = Math.floor(Date.now() / 1000); - - // Facebook requires scheduled time to be at least 10 minutes in the future - if (scheduledTimestamp < now + 600) { - throw new Error('Scheduled time must be at least 10 minutes in the future'); - } - - // Maximum 6 months in the future - const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60); - if (scheduledTimestamp > sixMonthsFromNow) { - throw new Error('Scheduled time cannot be more than 6 months in the future'); - } - - // For scheduled posts with photos - if (media) { - return await this.schedulePhotoPost(post, page, scheduledTimestamp, media); - } else { - return await this.scheduleTextPost(post, page, scheduledTimestamp); - } - } - - private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise { - const params = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - published: '0', - scheduled_publish_time: scheduledTimestamp.toString(), - }); - - const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookFeedResponse; - return data.id; - } - - private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise { - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - if (post.content) { - formData.append('message', post.content); - } - formData.append('published', '0'); - formData.append('scheduled_publish_time', scheduledTimestamp.toString()); - formData.append('source', blob, path.basename(media.originalUrl)); - - const url = `${this.baseUrl}/${page.pageId}/photos`; - - const response = await fetch(url, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookPhotoResponse; - return data.post_id || data.id; - } - - private async publishStory(post: Post, page: SocialPage, media: Media): Promise { - if (media.type === 'video') { - return this.publishVideoStory(post, page, media); - } - - let photoBuffer = await this.getMediaBuffer(media.originalUrl); - let originalFilename = path.basename(media.originalUrl); - - if (post.content && post.content.trim().length > 0) { - try { - const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content); - await minioService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`); - photoBuffer = imageWithText; - originalFilename = `story-${media.id}.png`; - console.log('Story image with text generated locally'); - } catch (error) { - console.error('Error generating story image with text, using original:', error); - } - } - - const mimeType = originalFilename.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([photoBuffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - formData.append('published', 'false'); // Important: must be unpublished first - formData.append('source', blob, originalFilename); - - const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; - - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json() as FacebookError; - throw new Error(`Facebook API error uploading photo for story: ${error.error.message} (code: ${error.error.code})`); - } - - const uploadData = await uploadResponse.json() as FacebookPhotoResponse; - const photoId = uploadData.id; - - // Step 2: Publish the photo as a story - const storyParams = new URLSearchParams({ - access_token: page.accessToken!, - photo_id: photoId, - }); - - const storyUrl = `${this.baseUrl}/${page.pageId}/photo_stories?${storyParams.toString()}`; - - const storyResponse = await fetch(storyUrl, { - method: 'POST', - }); - - if (!storyResponse.ok) { - const error = await storyResponse.json() as FacebookError; - throw new Error(`Facebook API error publishing story: ${error.error.message} (code: ${error.error.code})`); - } - - const storyData = await storyResponse.json() as { success: boolean; post_id: string }; - return storyData.post_id; - } - - private async publishVideoStory(post: Post, page: SocialPage, media: Media): Promise { - // Video stories require a 3-phase upload process: - // 1. START phase - Initialize the upload and get upload_url - // 2. UPLOAD phase - Upload the video to upload_url using file_url header - // 3. FINISH phase - Finalize and publish the story - - const videoUrl = resolvePublicUrl(media.originalUrl); - const accessToken = page.accessToken!; - - // Phase 1: START - Initialize the upload - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`; - - const startResponse = await fetch(startUrl, { - method: 'POST', - }); - - if (!startResponse.ok) { - const error = await startResponse.json() as FacebookError; - throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - - const startData = await startResponse.json() as { video_id: string; upload_url: string }; - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - - // Phase 2: UPLOAD - Upload the video using the upload_url - // IMPORTANT: file_url must be passed as a header, not a query parameter - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'file_url': videoUrl, - }, - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`); - } - - // Phase 3: FINISH - Finalize and publish the story - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - }); - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`; - - const finishResponse = await fetch(finishUrl, { - method: 'POST', - }); - - if (!finishResponse.ok) { - const error = await finishResponse.json() as FacebookError; - throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; - - // Return the post_id from the finish response - return finishData.post_id || videoId; - } - - /** - * Publie une vidéo en story Facebook en envoyant directement les données - * binaires (start → upload → finish sur /video_stories). Sert à doubler un - * Reel d'une story : la vidéo rendue est déjà en mémoire. - */ - async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise { - if (!page.accessToken) throw new Error('No access token found for this page'); - const accessToken = page.accessToken; - const fileSize = videoBuffer.length; - - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, { - method: 'POST', - }); - if (!startRes.ok) { - const error = await startRes.json() as FacebookError; - throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string }; - - const uploadRes = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: new Blob([videoBuffer], { type: 'video/mp4' }), - }); - if (!uploadRes.ok) { - throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`); - } - - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - }); - const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, { - method: 'POST', - }); - if (!finishRes.ok) { - const error = await finishRes.json() as FacebookError; - throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishRes.json() as { success: boolean; post_id?: string }; - console.log(`✅ Story vidéo publiée sur ${page.pageName}`); - return finishData.post_id || videoId; - } - - /** - * Publie une vidéo comme Reel Facebook - * - * Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases : - * 1. START - Initialise l'upload et récupère l'upload_url - * 2. UPLOAD - Upload la vidéo vers rupload.facebook.com - * 3. FINISH - Finalise et publie le Reel - * - * Spécifications vidéo requises : - * - Format : MP4, H.264 - * - Ratio : 9:16 (vertical) - * - Durée : 3-90 secondes - * - Résolution recommandée : 1080x1920 - * - * @param page - Page Facebook cible - * @param videoUrl - URL de la vidéo à publier - * @param description - Description du Reel (optionnel) - * @returns ID du Reel publié - */ - async publishReel( - page: SocialPage, - videoUrl: string, - description?: string - ): Promise { - if (page.platform !== 'facebook') { - throw new Error('This method only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - console.log('🎬 Publishing Reel to Facebook Feed & Reels via /videos endpoint:', { - pageId: page.pageId, - pageName: page.pageName, - hasDescription: !!description, - }); - - // Use the /videos endpoint instead of /video_reels - // This ensures the video appears on the Page Timeline (Feed) AND in Reels (if 9:16) - const params = new URLSearchParams({ - access_token: page.accessToken, - description: description || '', - file_url: videoUrl, - }); - - const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error publishing video/reel: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookVideoResponse; - const postId = data.post_id || data.id; - - console.log('✅ Reel published successfully to Feed & Reels! ID:', postId); - - return postId; - } - - /** - * Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → finish). - * Utilise l'endpoint /video_reels pour que la vidéo soit reconnue comme un Reel. - * - * Spécifications vidéo requises pour les Reels : - * - Format : MP4, H.264 - * - Ratio : 9:16 (vertical) - * - Durée : 3-90 secondes - * - Résolution recommandée : 1080x1920 - */ - async publishVideoFromBuffer( - page: SocialPage, - videoBuffer: Buffer | ArrayBuffer, - description?: string - ): Promise { - if (!page.accessToken) throw new Error('No access token found for this page'); - - const buf = Buffer.isBuffer(videoBuffer) ? videoBuffer : Buffer.from(videoBuffer); - const fileSize = buf.length; - const accessToken = page.accessToken; - - console.log('🎬 Publishing video as Facebook Reel via /video_reels endpoint:', { - pageId: page.pageId, - pageName: page.pageName, - size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, - }); - - // ── Phase 1: START - Initialize the Reel upload ────────────────────────── - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; - - const startRes = await fetch(startUrl, { method: 'POST' }); - const startRaw = await startRes.text(); - console.log('📤 Facebook Reel START response:', startRaw); - const startData = JSON.parse(startRaw) as any; - - if (!startRes.ok || startData.error) { - const subcode = startData.error?.error_subcode; - if (subcode === 1363042) { - throw new Error( - `Le token Facebook de la page "${page.pageName}" n'a pas la permission "publish_video". ` + - `Régénérez le token dans Graph API Explorer en ajoutant les permissions : ` + - `pages_manage_posts, pages_read_engagement, publish_video. ` + - `(code: ${startData.error?.code}, subcode: ${subcode})` - ); - } - throw new Error(`Facebook Reel START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${subcode})`); - } - - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - console.log(`📤 Reel upload initialized: video_id=${videoId}, upload_url=${uploadUrl}`); - - // ── Phase 2: UPLOAD - Upload binary data to the upload_url ─────────────── - const videoBlob = new Blob([buf], { type: 'video/mp4' }); - const uploadRes = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: videoBlob, - }); - - if (!uploadRes.ok) { - const errorText = await uploadRes.text(); - throw new Error(`Facebook Reel UPLOAD error: ${errorText}`); - } - console.log(`📤 Reel video data uploaded successfully (${fileSize} bytes)`); - - // ── Phase 3: FINISH - Finalize and publish the Reel ────────────────────── - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - video_state: 'PUBLISHED', - }); - - if (description) { - finishParams.append('description', description); - } - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; - - const finishRes = await fetch(finishUrl, { method: 'POST' }); - const finishRaw = await finishRes.text(); - console.log('📤 Facebook Reel FINISH response:', finishRaw); - const finishData = JSON.parse(finishRaw) as any; - - if (!finishRes.ok || finishData.error) { - throw new Error(`Facebook Reel FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`); - } - - const postId = finishData.post_id || videoId; - console.log('✅ Reel published successfully! ID:', postId); - return postId; - } - - /** - * Publie une vidéo comme Reel Facebook en uploadant directement les données binaires - * Utile quand on a la vidéo en base64 (après traitement FFmpeg) - * - * @param page - Page Facebook cible - * @param videoBuffer - Buffer de la vidéo - * @param description - Description du Reel (optionnel) - * @returns ID du Reel publié - */ - async publishReelFromBuffer( - page: SocialPage, - videoBuffer: ArrayBuffer, - description?: string - ): Promise { - if (page.platform !== 'facebook') { - throw new Error('This method only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - const accessToken = page.accessToken; - const fileSize = videoBuffer.byteLength; - - console.log('🎬 Publishing Reel from buffer:', { - pageId: page.pageId, - fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, - }); - - // Phase 1: START - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; - - const startResponse = await fetch(startUrl, { - method: 'POST', - }); - - if (!startResponse.ok) { - const error = await startResponse.json() as FacebookError; - throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - - const startData = await startResponse.json() as { video_id: string; upload_url: string }; - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - - // Phase 2: UPLOAD - Upload binary data directly - const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' }); - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: videoBlob, - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`); - } - - // Phase 3: FINISH - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - video_state: 'PUBLISHED', - }); - - if (description) { - finishParams.append('description', description); - } - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; - - const finishResponse = await fetch(finishUrl, { - method: 'POST', - }); - - if (!finishResponse.ok) { - const error = await finishResponse.json() as FacebookError; - throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; - - return finishData.post_id || videoId; - } -} - -export const facebookService = new FacebookService(); - diff --git a/server/services/facebook_oauth.ts b/server/services/facebook_oauth.ts deleted file mode 100644 index d412c9d..0000000 --- a/server/services/facebook_oauth.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Renouvellement des tokens Facebook. - * - * Le problème que ce service résout : un token de page saisi à la main finit - * toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du - * jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle - * d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au - * moment où une publication échoue. - * - * La parade est celle recommandée par Meta : - * 1. l'utilisateur autorise l'application (OAuth) ; - * 2. le code est échangé contre un token utilisateur court ; - * 3. ce token court est échangé contre un token utilisateur longue durée - * (~60 jours) — c'est lui que l'on conserve, chiffré ; - * 4. les tokens de page dérivés d'un token utilisateur longue durée - * n'expirent pas. - * - * Conserver le token utilisateur permet de régénérer un token de page sans - * intervention humaine, et de prolonger le token utilisateur lui-même avant - * son échéance : tant que l'utilisateur ne retire pas son autorisation, la - * chaîne se renouvelle toute seule. - */ - -import type { Request } from 'express'; -import { storage } from '../storage'; -import { resolvePublicBaseUrl } from '../utils/public_url'; - -const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0'; -const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`; - -/** Permissions nécessaires pour publier et lire les statistiques d'une page. */ -const DEFAULT_SCOPES = [ - 'pages_show_list', - 'pages_manage_posts', - 'pages_read_engagement', - 'pages_manage_engagement', - 'read_insights', - 'business_management', - 'instagram_basic', - 'instagram_content_publish', -].join(','); - -export interface FacebookLongLivedToken { - accessToken: string; - /** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */ - expiresAt: Date | null; -} - -export interface FacebookPageTarget { - id: string; - name: string; - accessToken: string; - followersCount: number; - /** Compte Instagram professionnel rattaché à la page, s'il existe. */ - instagram: { id: string; name: string } | null; -} - -/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */ -export interface FacebookTokenInfo { - isValid: boolean; - /** Nul = n'expire pas (cas normal d'un token de page). */ - expiresAt: Date | null; - scopes: string[]; - error: string | null; -} - -export class FacebookAuthError extends Error { - constructor(message: string) { - super(message); - this.name = 'FacebookAuthError'; - } -} - -export class FacebookOAuthService { - /** - * Identifiants de l'application Facebook (globaux à l'instance). - * La base prime sur les variables d'environnement. - */ - private async getCredentials(): Promise<{ appId: string; appSecret: string }> { - const config = await storage.getFacebookConfig(); - - const appId = config?.appId || process.env.FACEBOOK_APP_ID; - const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET; - - if (!appId || !appSecret) { - throw new FacebookAuthError( - "Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " + - 'votre application Facebook dans les paramètres.' - ); - } - - return { appId, appSecret }; - } - - async isConfigured(): Promise { - try { - await this.getCredentials(); - return true; - } catch { - return false; - } - } - - /** - * URI de redirection OAuth. `req` permet de la déduire du domaine réellement - * utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait - * `http://localhost:5555`, que Facebook rejette. - */ - getRedirectUri(req?: Request): string { - if (process.env.FACEBOOK_REDIRECT_URI) { - return process.env.FACEBOOK_REDIRECT_URI; - } - return `${resolvePublicBaseUrl(req)}/api/facebook/callback`; - } - - getScopes(): string { - return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES; - } - - /** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */ - async buildAuthorizationUrl(state: string, req?: Request): Promise { - const { appId } = await this.getCredentials(); - - const params = new URLSearchParams({ - client_id: appId, - redirect_uri: this.getRedirectUri(req), - state, - scope: this.getScopes(), - response_type: 'code', - }); - - return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`; - } - - /** - * Échange le code d'autorisation contre un token utilisateur **longue durée**. - * L'étape intermédiaire (token court) n'est jamais conservée : seul le token - * longue durée rend les tokens de page permanents. - */ - async exchangeCodeForUserToken(code: string, req?: Request): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', { - client_id: appId, - client_secret: appSecret, - redirect_uri: this.getRedirectUri(req), - code, - }, "échange du code d'autorisation"); - - if (!shortLived.access_token) { - throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.'); - } - - return this.extendUserToken(shortLived.access_token); - } - - /** - * Prolonge un token utilisateur. Appelé au retour d'OAuth (token court → - * ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée - * encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle. - */ - async extendUserToken(userToken: string): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>( - '/oauth/access_token', - { - grant_type: 'fb_exchange_token', - client_id: appId, - client_secret: appSecret, - fb_exchange_token: userToken, - }, - 'prolongation du jeton utilisateur' - ); - - if (!longLived.access_token) { - throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée."); - } - - return { - accessToken: longLived.access_token, - // expires_in absent ou nul = jeton sans expiration connue. - expiresAt: longLived.expires_in - ? new Date(Date.now() + longLived.expires_in * 1000) - : null, - }; - } - - /** - * Pages administrées par le compte, avec leur token de page. - * Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur - * longue durée : c'est le cœur du renouvellement automatique. - */ - async listPages(userToken: string): Promise { - const response = await this.graphGet<{ - data?: { - id: string; - name: string; - access_token: string; - fan_count?: number; - followers_count?: number; - instagram_business_account?: { id: string; name?: string; username?: string }; - }[]; - }>( - '/me/accounts', - { - fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}', - limit: '200', - access_token: userToken, - }, - 'récupération des pages' - ); - - return (response.data ?? []).map((page) => ({ - id: page.id, - name: page.name, - accessToken: page.access_token, - followersCount: page.followers_count ?? page.fan_count ?? 0, - instagram: page.instagram_business_account - ? { - id: page.instagram_business_account.id, - name: - page.instagram_business_account.username || - page.instagram_business_account.name || - page.name, - } - : null, - })); - } - - /** - * Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas - * quand le jeton expire : /debug_token le dit, ce qui permet de renouveler - * *avant* la panne plutôt qu'après. - */ - async inspectToken(token: string): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const response = await this.graphGet<{ - data?: { - is_valid?: boolean; - expires_at?: number; - scopes?: string[]; - error?: { message?: string }; - }; - }>( - '/debug_token', - { - input_token: token, - // Jeton d'application : `app_id|app_secret`, accepté par /debug_token. - access_token: `${appId}|${appSecret}`, - }, - 'inspection du jeton' - ); - - const data = response.data ?? {}; - - return { - isValid: data.is_valid === true, - // `expires_at: 0` signifie « n'expire pas » chez Meta. - expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null, - scopes: data.scopes ?? [], - error: data.error?.message ?? null, - }; - } - - private async graphGet( - endpoint: string, - params: Record, - context: string - ): Promise { - const url = new URL(`${GRAPH_URL}${endpoint}`); - Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); - - let response: Response; - try { - response = await fetch(url.toString()); - } catch (error) { - throw new FacebookAuthError( - `Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}` - ); - } - - const payload: any = await response.json().catch(() => ({})); - - if (!response.ok || payload?.error) { - const message = payload?.error?.message || `HTTP ${response.status}`; - throw new FacebookAuthError(`Facebook (${context}) : ${message}`); - } - - return payload as T; - } -} - -export const facebookOAuthService = new FacebookOAuthService(); diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts deleted file mode 100644 index 724dbe0..0000000 --- a/server/services/ffmpeg.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Client du service FFmpeg (conteneur Python `ffmpeg-service`). - * - * Le service rend le Reel puis expose le MP4 en téléchargement - * (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du - * JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire. - */ - -import fs from 'fs'; -import { Readable } from 'stream'; -import { pipeline } from 'stream/promises'; -import type { ReadableStream as WebReadableStream } from 'stream/web'; -import type { SrtCue } from '@shared/srt'; -import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices'; - -/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */ -const PROCESS_TIMEOUT_MS = 20 * 60_000; -const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; -// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer -const TTS_TIMEOUT_MS = 15 * 60_000; -const HEALTH_TIMEOUT_MS = 5_000; - -export interface ReelRenderOptions { - text?: string; - /** Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. */ - srtCues?: SrtCue[]; - musicUrl?: string; - ttsEnabled?: boolean; - ttsVoice?: string; - ttsEngine?: TtsEngine; - ttsStyle?: TtsStyle; - geminiApiKey?: string; - qwen?: QwenTarget; - fontSize?: number; - musicVolume?: number; - drawText?: boolean; - stabilize?: boolean; - watermarkUrl?: string; - storeName?: string; - enableEndingEffect?: boolean; - /** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */ - showLogo?: boolean; -} - -export interface ReelRenderResult { - video: Buffer; - duration: number; - ttsEngine?: string | null; - ttsVoice?: string | null; - warnings: string[]; -} - -/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */ -export interface PreparedReel { - videoPath: string; - audioPath: string | null; - totalDuration: number; - videoDuration: number; - logoStart: number | null; - /** Fondu final, calculé par le service pour ne jamais rogner la voix. */ - fadeStart: number | null; - fadeDuration: number | null; - words: TimedWord[]; - ttsEngine?: string | null; - ttsVoice?: string | null; - warnings: string[]; -} - -export interface TimedWord { - text: string; - start: number; - end: number; -} - -/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */ -export interface QwenTarget { - url: string; - apiKey?: string; -} - -export interface VoiceCatalog { - qwen: VoiceOption[]; - qwen_available: boolean; - /** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */ - qwen_status: { model?: string; device?: string; busy?: boolean } | null; - gemini: VoiceOption[]; - edge: VoiceOption[]; -} - -export interface VoicePreview { - audio: Buffer; - duration: number; - words: TimedWord[]; - engine: string; - voice: string; - warnings: string[]; -} - -interface FFmpegConfig { - apiUrl: string; - apiKey: string; -} - -/** Erreur renvoyée par le service, avec son message lisible. */ -export class FFmpegServiceError extends Error { - constructor(message: string, readonly status?: number) { - super(message); - this.name = 'FFmpegServiceError'; - } -} - -export class FFmpegService { - private config: FFmpegConfig | null = null; - - configure(apiUrl: string, apiKey: string): void { - this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey }; - console.log('🎬 FFmpeg Service configured:', this.config.apiUrl); - } - - private ensureConfigured(): FFmpegConfig { - if (!this.config) { - throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY)."); - } - return this.config; - } - - private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise { - const config = this.ensureConfigured(); - const { timeoutMs, headers, ...rest } = init; - const response = await fetch(`${config.apiUrl}${path}`, { - ...rest, - headers: { 'X-API-Key': config.apiKey, ...headers }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!response.ok) { - const body = await response.text(); - let detail = body; - try { - detail = JSON.parse(body).detail ?? body; - } catch { /* corps non JSON */ } - throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status); - } - return response; - } - - /** - * Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit. - * Lève FFmpegServiceError en cas d'échec (voix comprise). - */ - async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise { - const body = { - video_url: videoUrl, - text: options.text, - srt_cues: options.srtCues, - music_url: options.musicUrl, - tts_enabled: options.ttsEnabled ?? false, - tts_voice: options.ttsVoice, - tts_engine: options.ttsEngine, - tts_style: options.ttsStyle, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - font_size: options.fontSize ?? 64, - music_volume: options.musicVolume ?? 0.25, - draw_text: options.drawText ?? true, - stabilize: options.stabilize ?? false, - watermark_url: options.watermarkUrl, - store_name: options.storeName, - enable_ending_effect: options.enableEndingEffect ?? true, - show_watermark: options.showLogo ?? true, - }; - - console.log('🎬 Rendu du Reel :', { - videoUrl, - textLength: options.text?.length ?? 0, - music: !!options.musicUrl, - tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false, - }); - - const response = await this.call('/process-reel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - timeoutMs: PROCESS_TIMEOUT_MS, - }); - const data = await response.json() as { - job_id: string; - output_path: string; - duration: number; - tts_engine?: string | null; - tts_voice?: string | null; - warnings?: string[]; - }; - - try { - const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); - const video = Buffer.from(await file.arrayBuffer()); - for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`); - return { - video, - duration: data.duration, - ttsEngine: data.tts_engine, - ttsVoice: data.tts_voice, - warnings: data.warnings ?? [], - }; - } finally { - // Le fichier n'est plus utile au service une fois récupéré - this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS }) - .catch(() => { /* purgé plus tard par le service */ }); - } - } - - /** - * Prépare un rendu Remotion : image recadrée à la durée finale et piste son - * finale, écrites dans `targetDir`, plus le minutage des mots. - */ - async prepareReel( - videoUrl: string, - options: ReelRenderOptions & { hasLogo: boolean }, - targetDir: string, - ): Promise { - const response = await this.call('/prepare-reel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - video_url: videoUrl, - text: options.text, - srt_cues: options.srtCues, - music_url: options.musicUrl, - tts_enabled: options.ttsEnabled ?? false, - tts_voice: options.ttsVoice, - tts_engine: options.ttsEngine, - tts_style: options.ttsStyle, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - music_volume: options.musicVolume ?? 0.25, - draw_text: options.drawText ?? true, - stabilize: options.stabilize ?? false, - store_name: options.storeName, - enable_ending_effect: options.enableEndingEffect ?? true, - has_logo: options.hasLogo, - }), - timeoutMs: PROCESS_TIMEOUT_MS, - }); - const data = await response.json() as { - job_id: string; - video_path: string; - audio_path: string | null; - total_duration: number; - video_duration: number; - logo_start: number | null; - fade_start?: number | null; - fade_duration?: number | null; - words: TimedWord[]; - tts_engine?: string | null; - tts_voice?: string | null; - warnings?: string[]; - }; - - try { - await fs.promises.mkdir(targetDir, { recursive: true }); - const videoPath = `${targetDir}/video.mp4`; - await this.downloadTo(data.video_path, videoPath); - let audioPath: string | null = null; - if (data.audio_path) { - audioPath = `${targetDir}/audio.wav`; - await this.downloadTo(data.audio_path, audioPath); - } - for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`); - return { - videoPath, - audioPath, - totalDuration: data.total_duration, - videoDuration: data.video_duration, - logoStart: data.logo_start, - fadeStart: data.fade_start ?? null, - fadeDuration: data.fade_duration ?? null, - words: data.words ?? [], - ttsEngine: data.tts_engine, - ttsVoice: data.tts_voice, - warnings: data.warnings ?? [], - }; - } finally { - this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS }) - .catch(() => { /* purgé plus tard par le service */ }); - } - } - - /** Télécharge un fichier du service en flux, sans le charger en mémoire. */ - private async downloadTo(servicePath: string, target: string): Promise { - const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); - if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`); - await pipeline( - Readable.fromWeb(response.body as unknown as WebReadableStream), - fs.createWriteStream(target), - ); - } - - async healthCheck(): Promise { - try { - await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS }); - return true; - } catch { - return false; - } - } - - /** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */ - async listVoices(qwen?: QwenTarget): Promise { - const headers: Record = {}; - if (qwen?.url) headers['X-Qwen-Url'] = qwen.url; - if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey; - const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 }); - return await response.json() as VoiceCatalog; - } - - /** Génère la voix seule (aperçu), avec le minutage de chaque mot. */ - async previewVoice( - text: string, - options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {}, - ): Promise { - const response = await this.call('/preview-tts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text, - tts_voice: options.voice, - tts_engine: options.engine, - tts_style: options.style, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - }), - timeoutMs: TTS_TIMEOUT_MS, - }); - const data = await response.json() as { - audio_base64: string; - duration: number; - words: TimedWord[]; - engine: string; - voice: string; - warnings?: string[]; - }; - return { - audio: Buffer.from(data.audio_base64, 'base64'), - duration: data.duration, - words: data.words ?? [], - engine: data.engine, - voice: data.voice, - warnings: data.warnings ?? [], - }; - } -} - -export const ffmpegService = new FFmpegService(); diff --git a/server/services/freesound.ts b/server/services/freesound.ts deleted file mode 100644 index e1e694b..0000000 --- a/server/services/freesound.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * FreeSound Music API Service - * - * Intégration avec l'API FreeSound pour rechercher et récupérer des sons - * libres de droits pour les Reels. - * - * Documentation: https://freesound.org/docs/api/ - */ - -interface FreeSoundResult { - id: number; - name: string; - duration: number; - username: string; - tags: string[]; - license: string; - previews: { - 'preview-hq-mp3': string; - 'preview-lq-mp3': string; - 'preview-hq-ogg': string; - 'preview-lq-ogg': string; - }; - images: { - waveform_m: string; - waveform_l: string; - spectral_m: string; - spectral_l: string; - }; - download?: string; -} - -interface FreeSoundSearchResponse { - count: number; - next: string | null; - previous: string | null; - results: FreeSoundResult[]; -} - -export interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; // en secondes - previewUrl: string; // URL audio streaming - downloadUrl: string; // URL téléchargement - imageUrl: string; // Waveform image - license: string; -} - -interface MusicSearchParams { - minDuration?: number; // Durée min en secondes - maxDuration?: number; // Durée max en secondes - genre?: string; // Genre musical / tag - limit?: number; // Nombre de résultats (default: 10) - offset?: number; // Offset pour pagination - search?: string; // Recherche texte libre -} - -interface FreeSoundConfig { - clientId: string; - clientSecret: string; -} - -const FREESOUND_API_BASE = 'https://freesound.org/apiv2'; - -export class FreeSoundService { - private config: FreeSoundConfig | null = null; - - /** - * Configure le service avec les credentials FreeSound - */ - configure(clientId: string, clientSecret: string): void { - this.config = { clientId, clientSecret }; - console.log('🎵 FreeSound Service configured'); - } - - /** - * Vérifie que le service est configuré - */ - private ensureConfigured(): FreeSoundConfig { - if (!this.config) { - throw new Error('FreeSound Service not configured. Call configure() first.'); - } - return this.config; - } - - /** - * Convertit un résultat FreeSound en format MusicTrack unifié - */ - private mapResult(result: FreeSoundResult): MusicTrack { - return { - id: result.id.toString(), - title: result.name, - artist: result.username, - albumName: 'FreeSound', - duration: Math.round(result.duration), - previewUrl: result.previews['preview-hq-mp3'] || result.previews['preview-lq-mp3'], - downloadUrl: result.previews['preview-hq-mp3'], // On utilise le preview HQ - imageUrl: result.images?.waveform_m || '', - license: result.license, - }; - } - - /** - * Recherche des musiques par durée - * - * @param params - Paramètres de recherche - * @returns Liste de tracks correspondants - */ - async searchMusicByDuration(params: MusicSearchParams = {}): Promise { - const config = this.ensureConfigured(); - - const { - minDuration = 10, - maxDuration = 120, - genre, - limit = 10, - offset = 0, - search, - } = params; - - // Construction du filtre de durée - const durationFilter = `duration:[${minDuration} TO ${maxDuration}]`; - - // Recherche de musiques/sons musicaux - let query = search || 'music'; - if (genre) { - query = `${query} ${genre}`; - } - - const queryParams = new URLSearchParams({ - token: config.clientSecret, // FreeSound utilise le token dans l'URL pour les requêtes simples - query: query, - filter: durationFilter, - page_size: limit.toString(), - page: Math.floor(offset / limit + 1).toString(), - fields: 'id,name,duration,username,tags,license,previews,images', - sort: 'rating_desc', - }); - - const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`; - - console.log('🎵 Searching FreeSound:', { - query, - duration: `${minDuration}-${maxDuration}s`, - limit, - offset, - }); - - try { - const response = await fetch(url); - - if (!response.ok) { - const errorText = await response.text(); - console.error('FreeSound API error:', response.status, errorText); - throw new Error(`FreeSound API error: ${response.status}`); - } - - const data = await response.json() as FreeSoundSearchResponse; - - const tracks = data.results.map(result => this.mapResult(result)); - - console.log(`✅ Found ${tracks.length} tracks on FreeSound`); - return tracks; - - } catch (error) { - console.error('❌ FreeSound search error:', error); - throw error; - } - } - - /** - * Récupère les détails d'un son par son ID - */ - async getMusicDetails(trackId: string): Promise { - const config = this.ensureConfigured(); - - const url = `${FREESOUND_API_BASE}/sounds/${trackId}/?token=${config.clientSecret}&fields=id,name,duration,username,tags,license,previews,images`; - - try { - const response = await fetch(url); - - if (!response.ok) { - console.error('FreeSound API error:', response.status); - return null; - } - - const result = await response.json() as FreeSoundResult; - return this.mapResult(result); - - } catch (error) { - console.error('❌ FreeSound getMusicDetails error:', error); - return null; - } - } - - /** - * Récupère des sons populaires (utilisé comme fallback ou découverte) - */ - async getPopularTracks(limit: number = 10): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - token: config.clientSecret, - query: 'music loop beat', - filter: 'duration:[15 TO 90]', - page_size: limit.toString(), - fields: 'id,name,duration,username,tags,license,previews,images', - sort: 'downloads_desc', - }); - - const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`FreeSound API error: ${response.status}`); - } - - const data = await response.json() as FreeSoundSearchResponse; - return data.results.map(result => this.mapResult(result)); - - } catch (error) { - console.error('❌ FreeSound getPopularTracks error:', error); - return []; - } - } - - /** - * Retourne l'URL de téléchargement/preview - */ - getDownloadUrl(track: MusicTrack): string { - return track.downloadUrl; - } - - /** - * Vérifie la validité de la configuration - */ - async testConnection(): Promise { - try { - const tracks = await this.getPopularTracks(1); - return tracks.length > 0; - } catch { - return false; - } - } -} - -export const freeSoundService = new FreeSoundService(); diff --git a/server/services/imageProcessor.ts b/server/services/imageProcessor.ts deleted file mode 100644 index ebd6e3c..0000000 --- a/server/services/imageProcessor.ts +++ /dev/null @@ -1,301 +0,0 @@ -import sharp from "sharp"; -import { randomUUID } from "crypto"; -import path from "path"; -import fs from "fs/promises"; -import { createCanvas, loadImage, registerFont } from "canvas"; -import { removeHashtags } from "@shared/emoji"; -import { emojiRenderer } from "./emojiRenderer"; - -interface CropDimensions { - width: number; - height: number; -} - -interface ProcessedImages { - facebookFeed: string; - instagramFeed: string; - instagramStory: string; -} - -const CROP_FORMATS = { - facebookFeed: { width: 1200, height: 630 } as CropDimensions, - instagramFeed: { width: 1080, height: 1080 } as CropDimensions, - instagramStory: { width: 1080, height: 1920 } as CropDimensions, -}; - -// Register DejaVu Sans font for proper character rendering -try { - const fontPath = path.join(process.cwd(), "server", "fonts", "DejaVuSans.ttf"); - registerFont(fontPath, { family: 'DejaVu Sans' }); - console.log('✓ Font DejaVu Sans enregistrée avec succès'); -} catch (error) { - console.error('Erreur lors de l\'enregistrement de la police:', error); -} - -export class ImageProcessor { - private uploadsDir: string; - - constructor() { - this.uploadsDir = path.join(process.cwd(), "uploads"); - this.ensureUploadsDirExists(); - } - - private async ensureUploadsDirExists() { - try { - await fs.access(this.uploadsDir); - } catch { - await fs.mkdir(this.uploadsDir, { recursive: true }); - } - } - - async processImage(buffer: Buffer, originalFilename: string): Promise { - const fileExt = path.extname(originalFilename); - const baseId = randomUUID(); - - const processedImages: ProcessedImages = { - facebookFeed: "", - instagramFeed: "", - instagramStory: "", - }; - - // Process Facebook Feed (1200x630) - const fbFeedFilename = `${baseId}-fb-feed${fileExt}`; - const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, { - fit: "cover", - position: "center", - }) - .toFile(fbFeedPath); - processedImages.facebookFeed = `/uploads/${fbFeedFilename}`; - - // Process Instagram Feed (1080x1080) - const igFeedFilename = `${baseId}-ig-feed${fileExt}`; - const igFeedPath = path.join(this.uploadsDir, igFeedFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, { - fit: "cover", - position: "center", - }) - .toFile(igFeedPath); - processedImages.instagramFeed = `/uploads/${igFeedFilename}`; - - // Process Instagram Story (1080x1920) - const igStoryFilename = `${baseId}-ig-story${fileExt}`; - const igStoryPath = path.join(this.uploadsDir, igStoryFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, { - fit: "cover", - position: "center", - }) - .toFile(igStoryPath); - processedImages.instagramStory = `/uploads/${igStoryFilename}`; - - return processedImages; - } - - async saveOriginalFile(buffer: Buffer, filename: string): Promise { - const uniqueFilename = `${randomUUID()}-${filename}`; - const filePath = path.join(this.uploadsDir, uniqueFilename); - await fs.writeFile(filePath, buffer); - return `/uploads/${uniqueFilename}`; - } - - async deleteFile(filePath: string): Promise { - try { - const fullPath = path.join(process.cwd(), filePath); - await fs.unlink(fullPath); - } catch (error) { - console.error("Error deleting file:", error); - } - } - - async addTextToStoryImage(imageUrl: string, text: string): Promise { - const STORY_WIDTH = 1080; - const STORY_HEIGHT = 1920; - const TEXT_BOX_HEIGHT_RATIO = 0.25; - const MIN_FONT_SIZE = 12; - const MAX_FONT_SIZE = 72; - const PADDING = 40; - const LINE_HEIGHT_MULTIPLIER = 1.3; - - // Remove hashtags from text while keeping emojis - const cleanText = removeHashtags(text); - - // Download image from URL first (Sharp can't read URLs directly) - const imageResponse = await fetch(imageUrl); - if (!imageResponse.ok) { - throw new Error(`Failed to fetch image from URL: ${imageUrl}`); - } - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - - // Pre-process image with sharp to apply rotation - const rotationBuffer = await sharp(imageBuffer) - .rotate() // Apply EXIF rotation - .toBuffer(); - - const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT); - const ctx = canvas.getContext('2d'); - - const image = await loadImage(rotationBuffer); - - const imgWidth = image.width; - const imgHeight = image.height; - const targetRatio = STORY_WIDTH / STORY_HEIGHT; - const imgRatio = imgWidth / imgHeight; - - let drawWidth, drawHeight, offsetX, offsetY; - - if (imgRatio > targetRatio) { - drawHeight = STORY_HEIGHT; - drawWidth = imgWidth * (STORY_HEIGHT / imgHeight); - offsetX = (STORY_WIDTH - drawWidth) / 2; - offsetY = 0; - } else { - drawWidth = STORY_WIDTH; - drawHeight = imgHeight * (STORY_WIDTH / imgWidth); - offsetX = 0; - offsetY = (STORY_HEIGHT - drawHeight) / 2; - } - - ctx.fillStyle = 'black'; - ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT); - ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); - - const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO; - const textBoxY = STORY_HEIGHT - textBoxHeight; - - ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight); - - const maxTextWidth = STORY_WIDTH - (PADDING * 2); - const maxTextHeight = textBoxHeight - (PADDING * 2); - - let fontSize = MAX_FONT_SIZE; - let lines: string[] = []; - let totalTextHeight = 0; - - while (fontSize >= MIN_FONT_SIZE) { - ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - - lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - totalTextHeight = lines.length * lineHeight; - - if (totalTextHeight <= maxTextHeight) { - break; - } - - fontSize -= 2; - } - - if (fontSize < MIN_FONT_SIZE) { - fontSize = MIN_FONT_SIZE; - ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - totalTextHeight = lines.length * lineHeight; - - const maxLines = Math.floor(maxTextHeight / lineHeight); - if (lines.length > maxLines) { - lines = lines.slice(0, maxLines); - if (lines[maxLines - 1]) { - lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...'; - } - totalTextHeight = maxLines * lineHeight; - } - } - - ctx.fillStyle = 'white'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2; - - for (let index = 0; index < lines.length; index++) { - const line = lines[index]; - const y = startY + (index * lineHeight); - const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize); - const x = (STORY_WIDTH - lineWidth) / 2; // Center the line - await emojiRenderer.drawText(line, ctx, x, y, fontSize); - } - - return canvas.toBuffer('image/png'); - } - - private wrapText(ctx: any, text: string, maxWidth: number): string[] { - const words = text.split(' '); - const lines: string[] = []; - let currentLine = ''; - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const metrics = ctx.measureText(testLine); - - if (metrics.width > maxWidth && currentLine) { - // Current line would exceed width, push it and start new line - lines.push(currentLine); - currentLine = word; - - // Check if the word itself is too long for a single line - const wordMetrics = ctx.measureText(word); - if (wordMetrics.width > maxWidth) { - // Word is too long, need to break it into chunks - const chunks = this.breakLongWord(ctx, word, maxWidth); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } - } else if (metrics.width > maxWidth && !currentLine) { - // First word is too long, need to break it - const chunks = this.breakLongWord(ctx, word, maxWidth); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } else { - currentLine = testLine; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; - } - - /** - * Breaks a long word into chunks that fit within maxWidth - */ - private breakLongWord(ctx: any, word: string, maxWidth: number): string[] { - const chunks: string[] = []; - let currentChunk = ''; - - for (let i = 0; i < word.length; i++) { - const char = word[i]; - const testChunk = currentChunk + char; - const metrics = ctx.measureText(testChunk); - - if (metrics.width > maxWidth && currentChunk) { - chunks.push(currentChunk); - currentChunk = char; - } else { - currentChunk = testChunk; - } - } - - if (currentChunk) { - chunks.push(currentChunk); - } - - return chunks.length > 0 ? chunks : [word]; - } -} - -export const imageProcessor = new ImageProcessor(); diff --git a/server/services/jamendo.ts b/server/services/jamendo.ts deleted file mode 100644 index 34e1541..0000000 --- a/server/services/jamendo.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Jamendo Music API Service - * - * Intégration avec l'API Jamendo pour rechercher et récupérer des musiques - * libres de droits pour les Reels. - * - * Documentation: https://developer.jamendo.com/v3.0 - */ - -interface JamendoTrack { - id: string; - name: string; - duration: number; // en secondes - artist_id: string; - artist_name: string; - album_name: string; - album_id: string; - license_ccurl: string; - position: number; - releasedate: string; - album_image: string; - audio: string; // URL du fichier audio complet - audiodownload: string; // URL de téléchargement - prourl: string; - shorturl: string; - shareurl: string; - waveform: string; - image: string; - audiodownload_allowed: boolean; -} - -interface JamendoResponse { - headers: { - status: string; - code: number; - error_message: string; - warnings: string; - results_count: number; - }; - results: JamendoTrack[]; -} - -export interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; // en secondes - previewUrl: string; // URL audio streaming - downloadUrl: string; // URL téléchargement - imageUrl: string; // Pochette album - license: string; -} - -interface MusicSearchParams { - minDuration?: number; // Durée min en secondes - maxDuration?: number; // Durée max en secondes - genre?: string; // Genre musical (ex: "pop", "rock", "electronic") - mood?: string; // Ambiance (ex: "happy", "sad", "energetic") - limit?: number; // Nombre de résultats (default: 10) - offset?: number; // Offset pour pagination - search?: string; // Recherche texte libre -} - -interface JamendoConfig { - clientId: string; -} - -const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0'; - -// Mapping des genres Jamendo -const GENRE_MAPPING: Record = { - 'pop': 'pop', - 'rock': 'rock', - 'electronic': 'electronic', - 'hiphop': 'hiphop', - 'jazz': 'jazz', - 'classical': 'classical', - 'ambient': 'ambient', - 'chill': 'chillout', - 'dance': 'dance', - 'indie': 'indie', -}; - -export class JamendoService { - private config: JamendoConfig | null = null; - - /** - * Configure le service avec le Client ID Jamendo - */ - configure(clientId: string): void { - this.config = { clientId }; - console.log('🎵 Jamendo Service configured'); - } - - /** - * Vérifie que le service est configuré - */ - private ensureConfigured(): JamendoConfig { - if (!this.config) { - throw new Error('Jamendo Service not configured. Call configure() first.'); - } - return this.config; - } - - /** - * Convertit un track Jamendo en format MusicTrack unifié - */ - private mapTrack(track: JamendoTrack): MusicTrack { - return { - id: track.id, - title: track.name, - artist: track.artist_name, - albumName: track.album_name, - duration: track.duration, - previewUrl: track.audio, - downloadUrl: track.audiodownload, - imageUrl: track.album_image || track.image, - license: track.license_ccurl, - }; - } - - /** - * Recherche des musiques par durée - * - * @param params - Paramètres de recherche - * @returns Liste de tracks correspondants - */ - async searchMusicByDuration(params: MusicSearchParams = {}): Promise { - const config = this.ensureConfigured(); - - const { - minDuration = 10, - maxDuration = 120, - genre, - limit = 10, - offset = 0, - search, - } = params; - - // Construction de l'URL avec les paramètres - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - limit: limit.toString(), - offset: offset.toString(), - include: 'musicinfo', - audioformat: 'mp32', // MP3 320kbps - durationbetween: `${minDuration}_${maxDuration}`, - order: 'popularity_total', - }); - - // Ajouter le genre si spécifié - if (genre && GENRE_MAPPING[genre.toLowerCase()]) { - queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]); - } else if (genre) { - queryParams.append('tags', genre.toLowerCase()); - } - - // Ajouter la recherche texte si spécifiée - if (search) { - queryParams.append('namesearch', search); - } - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - console.log('🎵 Searching Jamendo:', { - duration: `${minDuration}-${maxDuration}s`, - genre, - limit, - offset, - }); - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0) { - throw new Error(`Jamendo API error: ${data.headers.error_message}`); - } - - const tracks = data.results.map(track => this.mapTrack(track)); - - console.log(`✅ Found ${tracks.length} tracks on Jamendo`); - return tracks; - - } catch (error) { - console.error('❌ Jamendo search error:', error); - throw error; - } - } - - /** - * Récupère les détails d'un track par son ID - */ - async getMusicDetails(trackId: string): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - id: trackId, - include: 'musicinfo', - audioformat: 'mp32', - }); - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0 || data.results.length === 0) { - return null; - } - - return this.mapTrack(data.results[0]); - - } catch (error) { - console.error('❌ Jamendo getMusicDetails error:', error); - return null; - } - } - - /** - * Récupère des tracks populaires (utilisé comme fallback ou découverte) - */ - async getPopularTracks(limit: number = 10): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - limit: limit.toString(), - include: 'musicinfo', - audioformat: 'mp32', - order: 'popularity_week', - durationbetween: '15_90', // Durée idéale pour Reels - }); - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0) { - throw new Error(`Jamendo API error: ${data.headers.error_message}`); - } - - return data.results.map(track => this.mapTrack(track)); - - } catch (error) { - console.error('❌ Jamendo getPopularTracks error:', error); - return []; - } - } - - /** - * Télécharge l'URL du fichier audio (avec redirection si nécessaire) - * Note: Jamendo fournit directement les URLs de téléchargement - */ - getDownloadUrl(track: MusicTrack): string { - // L'URL de téléchargement Jamendo est directement utilisable - return track.downloadUrl; - } - - /** - * Vérifie la validité de la configuration - */ - async testConnection(): Promise { - try { - const tracks = await this.getPopularTracks(1); - return tracks.length > 0; - } catch { - return false; - } - } -} - -export const jamendoService = new JamendoService(); diff --git a/server/services/media-purge.ts b/server/services/media-purge.ts deleted file mode 100644 index a99c2e1..0000000 --- a/server/services/media-purge.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { db } from '../db'; -import { media } from '@shared/schema'; -import { eq, lt, and } from 'drizzle-orm'; -import { minioService as storageService } from './minio'; - -/** - * Purge old local media to save disk space: - * - Images older than 30 days - * - Videos older than 7 days - */ -export async function purgeOldMedia() { - console.log('[Media Purge] Starting automatic purge of old media...'); - - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const oldImages = await db.query.media.findMany({ - where: (m, { and, lt, eq }) => and(eq(m.type, 'image'), lt(m.createdAt, thirtyDaysAgo)) - }); - - const oldVideos = await db.query.media.findMany({ - where: (m, { and, lt, eq }) => and(eq(m.type, 'video'), lt(m.createdAt, sevenDaysAgo)) - }); - - const toDelete = [...oldImages, ...oldVideos]; - - if (toDelete.length === 0) { - console.log('[Media Purge] No old media found to purge.'); - return; - } - - console.log(`[Media Purge] Found ${oldImages.length} old images (>30 days) and ${oldVideos.length} old videos (>7 days) to purge.`); - - let successCount = 0; - let errorCount = 0; - - for (const item of toDelete) { - try { - if (item.cloudinaryPublicId) { - await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image'); - } - // La vignette n'a plus de raison d'être une fois la ligne supprimée - if (item.thumbnailUrl?.startsWith('/uploads/')) { - await storageService - .deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image') - .catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err)); - } - await db.delete(media).where(eq(media.id, item.id)); - successCount++; - } catch (err) { - console.error(`[Media Purge] Failed to delete media ${item.id}:`, err); - errorCount++; - } - } - - console.log(`[Media Purge] Purge completed. Deleted: ${successCount}, Failed: ${errorCount}.`); - } catch (error) { - console.error('[Media Purge] Fatal error during media purge process:', error); - } -} diff --git a/server/services/minio.ts b/server/services/minio.ts deleted file mode 100644 index d99537f..0000000 --- a/server/services/minio.ts +++ /dev/null @@ -1,143 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -const UPLOADS_BASE = path.resolve(process.cwd(), 'uploads'); - -function ensureDir(dir: string): void { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - -/** - * Builds the public URL for a locally stored file. - * Returns a relative path so it works on any domain/protocol. - * objectKey is the relative path from uploads/ (e.g. "media/123-file.jpg"). - */ -export function buildMinioUrl(_bucketName: string, objectKey: string, _publicUrlOverride?: string | null): string { - return `/uploads/${objectKey}`; -} - -/** - * Resolves a possibly-relative URL to an absolute URL for use by external - * services (Facebook API, Instagram API) that must download the file. - * Uses APP_URL env var (public-facing domain). - */ -export function resolvePublicUrl(url: string): string { - if (!url || !url.startsWith('/')) return url; - const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, ''); - return `${base}${url}`; -} - -/** - * Resolves a possibly-relative URL to an absolute URL for use by internal - * Docker services (FFmpeg, etc.) that communicate via internal Docker network. - * Uses INTERNAL_APP_URL env var. - */ -export function resolveInternalUrl(url: string): string { - if (!url || !url.startsWith('/')) return url; - const base = (process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, ''); - return `${base}${url}`; -} - -/** - * Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante. - * Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL - * (upload TikTok, Reels Facebook). - */ -export async function readMediaBuffer(url: string): Promise { - if (url.startsWith('/')) { - const localPath = path.join(process.cwd(), url); - if (fs.existsSync(localPath)) { - return await fs.promises.readFile(localPath); - } - } - - const absoluteUrl = resolvePublicUrl(url); - const response = await fetch(absoluteUrl); - if (!response.ok) { - throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`); - } - return Buffer.from(await response.arrayBuffer()); -} - -class LocalStorageService { - async uploadMedia( - file: Buffer | string, - fileName: string, - _userId: string, - _mimeType: string - ): Promise<{ - publicId: string; - originalUrl: string; - facebookFeedUrl: string | null; - instagramFeedUrl: string | null; - instagramStoryUrl: string | null; - }> { - const dir = path.join(UPLOADS_BASE, 'media'); - ensureDir(dir); - - const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `media/${Date.now()}-${safeName}`; - const filePath = path.join(UPLOADS_BASE, objectKey); - - const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file; - fs.writeFileSync(filePath, body); - - const url = buildMinioUrl('', objectKey); - return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url }; - } - - async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise { - const filePath = path.join(UPLOADS_BASE, publicId); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } - - async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise { - const dir = path.join(UPLOADS_BASE, 'stories'); - ensureDir(dir); - - const safeName = path.basename(originalFileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `stories/story-${Date.now()}-${safeName}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return buildMinioUrl('', objectKey); - } - - /** - * Range une vignette vidéo. Elle survit volontairement à la suppression de la - * vidéo source, pour que l'historique reste illustré. - */ - async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const dir = path.join(UPLOADS_BASE, 'thumbnails'); - ensureDir(dir); - - const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `thumbnails/${safeName}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return { publicId: objectKey, url: buildMinioUrl('', objectKey) }; - } - - async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const dir = path.join(UPLOADS_BASE, 'logos'); - ensureDir(dir); - - const ext = path.extname(fileName) || '.png'; - const objectKey = `logos/logo-${Date.now()}${ext}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return { publicId: objectKey, url: buildMinioUrl('', objectKey) }; - } - - async deleteLogo(publicId: string): Promise { - const filePath = path.join(UPLOADS_BASE, publicId); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } -} - -export const minioService = new LocalStorageService(); diff --git a/server/services/openrouter.ts b/server/services/openrouter.ts deleted file mode 100644 index 98d2fc5..0000000 --- a/server/services/openrouter.ts +++ /dev/null @@ -1,289 +0,0 @@ -interface ProductInfo { - name: string; - description?: string; - price?: string; - features?: string[]; - [key: string]: any; -} - -interface GeneratedText { - variant: string; - text: string; - characterCount: number; -} - -import crypto from 'crypto'; -import { storage } from '../storage'; - -/** - * Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format - * JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de - * déverser une page HTML entière dans l'interface. - */ -function extractUpstreamMessage(body: string): string { - const trimmed = (body || '').trim(); - if (!trimmed) return 'aucun détail fourni'; - - try { - const parsed = JSON.parse(trimmed); - const message = parsed?.error?.message || parsed?.message || parsed?.error; - if (typeof message === 'string' && message.trim()) { - return message.trim().slice(0, 300); - } - } catch { - // Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple) - } - - return trimmed.replace(/\s+/g, ' ').slice(0, 300); -} - -/** - * Traduit une erreur de génération en réponse HTTP exploitable côté interface. - * Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause - * (configuration absente, clé invalide, crédits épuisés) est connue du serveur. - */ -export function describeGenerationError(error: unknown): { status: number; message: string } { - const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération'; - - // Problème de configuration : l'administrateur peut le corriger lui-même - if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) { - return { status: 400, message }; - } - - // Refus du service tiers - if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) { - return { status: 502, message }; - } - - return { status: 500, message }; -} - -export class OpenRouterService { - private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; - - async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise { - // Prefer the requesting user's own configuration; fall back to the most - // recently saved shared configuration if this user hasn't set one up. - const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig()); - - if (!config) { - throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.'); - } - - if (!config.apiKey || !config.apiKey.trim()) { - throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.'); - } - - const prompt = this.buildPrompt(productInfo, config.systemPrompt); - - // Use provided model or fall back to config model - const modelToUse = modelOverride || config.model; - - const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8); - console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`); - - try { - const response = await fetch(this.baseUrl, { - method: "POST", - headers: { - "Authorization": `Bearer ${config.apiKey.trim()}`, - "Content-Type": "application/json", - "HTTP-Referer": process.env.APP_URL || "http://localhost:5555", - "X-Title": "Social Flow" - }, - body: JSON.stringify({ - model: modelToUse, - messages: [ - { - role: "user", - content: prompt - } - ], - temperature: 0.8, - max_tokens: 1000 - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('OpenRouter API Error Response:', errorText); - // Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle - // inconnu…) est la seule information exploitable : on le fait remonter - // jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur. - throw new Error( - `OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` + - `avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}` - ); - } - - const data = await response.json(); - - if (!data.choices || !data.choices[0]) { - console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: modelToUse }); - throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.'); - } - - const content = data.choices[0]?.message?.content || ""; - - return this.parseGeneratedText(content); - } catch (error) { - console.error("Error generating text with OpenRouter:", error); - throw error; - } - } - - private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string { - const features = productInfo.features?.join(", ") || ""; - - const productDetails = `Nom: ${productInfo.name} -${productInfo.description ? `Description: ${productInfo.description}` : ''} -${productInfo.price ? `Prix: ${productInfo.price}` : ''} -${features ? `Caractéristiques: ${features}` : ''}`; - - return `${systemPrompt} - -Informations produit: -${productDetails} - -Format de réponse: -VERSION 1 - DYNAMIQUE: -[texte] - -VERSION 2 - INFORMATIVE: -[texte] - -VERSION 3 - ÉMOTIONNELLE: -[texte]`; - } - - private parseGeneratedText(content: string): GeneratedText[] { - // Normaliser les retours à la ligne - const normalizedContent = content.replace(/\r\n/g, '\n').trim(); - - console.log('🔍 Parsing AI response (length:', normalizedContent.length, ')'); - - // Stratégie 1: Détection des headers VERSION avec parsing intelligent - const variants = this.tryMultipleParsingStrategies(normalizedContent); - - if (variants.length > 0) { - console.log('✅ Parsed successfully:', variants.length, 'variants'); - return variants; - } - - // Fallback: retourner le texte complet - console.log('⚠️ Parsing failed, using fallback'); - return [{ - variant: "Généré par IA", - text: normalizedContent, - characterCount: normalizedContent.length - }]; - } - - private tryMultipleParsingStrategies(content: string): GeneratedText[] { - // Stratégie 1: Split par headers de VERSION (le plus flexible) - const strategy1 = this.parseByVersionHeaders(content); - if (strategy1.length >= 3) return strategy1; - - // Stratégie 2: Regex avec markdown **VERSION N:** - const strategy2 = this.parseWithMarkdownHeaders(content); - if (strategy2.length >= 3) return strategy2; - - // Stratégie 3: Regex simple sans markdown - const strategy3 = this.parseWithSimpleHeaders(content); - if (strategy3.length >= 3) return strategy3; - - // Retourner le meilleur résultat obtenu - return strategy1.length > 0 ? strategy1 : (strategy2.length > 0 ? strategy2 : strategy3); - } - - private parseByVersionHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - // Chercher tous les patterns VERSION N (très flexible) - const versionPattern = /(?:\*{2})?VERSION\s+(\d+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?/gi; - const matches = Array.from(content.matchAll(versionPattern)); - - if (matches.length < 3) return variants; - - // Extraire le texte entre chaque header - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; - const versionNum = match[1]; - const style = match[2]; - const startPos = match.index! + match[0].length; - const endPos = i < matches.length - 1 ? matches[i + 1].index! : content.length; - - let text = content.substring(startPos, endPos).trim(); - - // Nettoyer le texte (enlever les séparateurs, notes entre parenthèses au début, etc.) - text = text.replace(/^[-*]{3,}\s*/gm, ''); // Enlever les séparateurs - text = text.replace(/^\([^)]+\)\s*/gm, ''); // Enlever les notes entre parenthèses - text = text.trim(); - - if (text.length > 0) { - variants.push({ - variant: `Version ${versionNum} - ${this.capitalizeFirst(style.toLowerCase())}`, - text, - characterCount: text.length - }); - } - } - - return variants; - } - - private parseWithMarkdownHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - const v1 = content.match(/\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+2|$)/i); - const v2 = content.match(/\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+3|$)/i); - const v3 = content.match(/\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n([\s\S]*?)$/i); - - if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length }); - if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length }); - if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length }); - - return variants; - } - - private parseWithSimpleHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - const v1 = content.match(/VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n([\s\S]*?)(?=VERSION\s+2|$)/i); - const v2 = content.match(/VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n([\s\S]*?)(?=VERSION\s+3|$)/i); - const v3 = content.match(/VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n([\s\S]*?)$/i); - - if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length }); - if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length }); - if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length }); - - return variants; - } - - private capitalizeFirst(text: string): string { - return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); - } - - async getAvailableModels(): Promise { - try { - const response = await fetch("https://openrouter.ai/api/v1/models", { - method: "GET", - headers: { - "Content-Type": "application/json", - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - return data.data || []; - } catch (error) { - console.error("Error fetching OpenRouter models:", error); - throw error; - } - } -} - -export const openRouterService = new OpenRouterService(); diff --git a/server/services/reels/assets.ts b/server/services/reels/assets.ts deleted file mode 100644 index 6c82300..0000000 --- a/server/services/reels/assets.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du - * magasin), jusqu'ici recopiée dans chaque route. - */ - -import type { QwenTarget } from "../ffmpeg"; -import { storage } from "../../storage"; -import { buildMinioUrl, resolveInternalUrl } from "../minio"; - -/** - * URL de la musique téléchargeable par le service FFmpeg (réseau Docker - * interne : l'URL publique HTTPS n'y est pas joignable). - */ -export async function resolveMusicUrl( - musicTrackId?: string, - musicUrl?: string, -): Promise { - if (musicUrl) return resolveInternalUrl(musicUrl); - if (!musicTrackId?.startsWith("internal_")) return undefined; - - try { - const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length)); - return track ? resolveInternalUrl(track.url) : undefined; - } catch (error) { - console.error("⚠️ [Reels] Piste audio introuvable :", error); - return undefined; - } -} - -/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */ -export async function resolveLogoPath(): Promise { - try { - const config = await storage.getCloudinaryConfig(); - if (config?.logoPublicId) { - return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl); - } - } catch (error) { - console.error("⚠️ [Reels] Configuration du logo illisible :", error); - } - return undefined; -} - -/** - * Clé Gemini : configuration de l'application, sinon variable d'environnement. - * Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue. - */ -export async function resolveGeminiApiKey(ttsEngine?: string): Promise { - if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined; - const appConfig = await storage.getAppConfig(); - return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined; -} - -/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */ -export async function resolveQwenTarget(ttsEngine?: string): Promise { - if (ttsEngine !== "qwen") return undefined; - return qwenTargetFromConfig(await storage.getAppConfig()); -} - -/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */ -export function normalizeQwenUrl(value: unknown): string | null { - if (typeof value !== "string" || !value.trim()) return null; - try { - const url = new URL(value.trim()); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.toString().replace(/\/+$/, ""); - } catch { - return null; - } -} - -export function qwenTargetFromConfig( - appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined, -): QwenTarget | undefined { - if (!appConfig?.qwenTtsUrl) return undefined; - return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined }; -} - -/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */ -export async function resolveStoreName( - userId: string, - preferredPageId?: string, -): Promise { - try { - if (preferredPageId) { - const page = await storage.getSocialPage(preferredPageId); - if (page) return page.pageName; - } - const pages = await storage.getSocialPages(userId); - return pages[0]?.pageName; - } catch (error) { - console.error("⚠️ [Reels] Nom du magasin introuvable :", error); - return undefined; - } -} diff --git a/server/services/reels/index.ts b/server/services/reels/index.ts deleted file mode 100644 index 4de4b98..0000000 --- a/server/services/reels/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Point d'entrée des Reels : enregistre les traitements de la file et démarre - * le worker. - */ - -import { registerReelJobHandler, startReelWorker } from "./queue"; -import { runVideoReelJob } from "./videoPipeline"; - -export function startReelProcessing(): Promise { - registerReelJobHandler("video", runVideoReelJob); - return startReelWorker(); -} diff --git a/server/services/reels/publish.ts b/server/services/reels/publish.ts deleted file mode 100644 index bc88dbe..0000000 --- a/server/services/reels/publish.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et - * images. - * - * La vidéo est toujours envoyée en binaire : les fichiers vivent sous - * /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le - * cas de l'ancien `publishReel(originalUrl)`). - */ - -import type { Media, SocialPage } from "@shared/schema"; -import { storage } from "../../storage"; -import { minioService } from "../minio"; -import { createVideoThumbnail } from "../thumbnail"; -import { facebookService } from "../facebook"; -import { tiktokService } from "../tiktok"; - -export interface PagePublishResult { - pageId: string; - success: boolean; - reelId?: string; - error?: string; - /** Échec de la story associée : n'empêche pas le Reel d'être publié. */ - storyError?: string; -} - -/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */ -export async function storeRenderedVideo( - userId: string, - videoBuffer: Buffer, - fileName: string, -): Promise { - const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4"); - // Vignette extraite maintenant : la vidéo sera supprimée du disque après - // publication, mais l'historique doit rester illustré. - const thumbnailUrl = await createVideoThumbnail(videoBuffer); - - return storage.createMedia({ - userId, - type: "video", - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - thumbnailUrl, - fileName, - fileSize: videoBuffer.length, - }); -} - -/** - * Publie (ou planifie) la vidéo sur chaque page, crée les entrées - * `scheduled_posts` et met à jour le statut du post. - * - * Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok - * n'a pas de stories). - */ -export async function publishReelToPages(options: { - postId: string; - pageIds: string[]; - videoBuffer: Buffer; - description: string; - scheduledFor?: string; - alsoStory?: boolean; -}): Promise { - const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options; - const results: PagePublishResult[] = []; - - for (const pageId of pageIds) { - try { - const page = await storage.getSocialPage(pageId); - if (!page) { - results.push({ pageId, success: false, error: "Page non trouvée" }); - continue; - } - if (page.platform !== "facebook" && page.platform !== "tiktok") { - results.push({ - pageId, - success: false, - error: `Plateforme non supportée pour les reels : ${page.platform}`, - }); - continue; - } - - const scheduledPost = await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "reel", - scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(), - }); - const withStory = alsoStory && page.platform === "facebook"; - - if (scheduledFor) { - // Le planificateur publiera à l'heure dite - if (withStory) { - await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "story", - scheduledAt: new Date(scheduledFor), - }); - } - results.push({ pageId, success: true, reelId: "scheduled" }); - continue; - } - - console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`); - if (page.platform === "facebook") { - const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description); - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - externalPostId: reelId, - }); - const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined; - results.push({ pageId, success: true, reelId, storyError }); - } else { - // TikTok finalise la publication de son côté : l'envoi est marqué comme - // effectué pour ne pas republier, et le poller de statut renseignera - // l'identifiant définitif du post. - const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description); - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - publishId, - publishStatus: "PROCESSING_UPLOAD", - }); - results.push({ pageId, success: true, reelId: publishId }); - } - } catch (error) { - console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error); - results.push({ - pageId, - success: false, - error: error instanceof Error ? error.message : "Erreur inconnue", - }); - } - } - - const succeeded = results.filter((r) => r.success).length; - const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed"; - await storage.updatePost(postId, { status }); - - return results; -} - -/** - * Publie la story d'une page juste après son Reel : un échec est consigné sur - * son entrée planifiée (visible dans l'historique) et renvoyé, sans faire - * échouer le Reel. - */ -async function publishStory( - postId: string, - page: SocialPage, - videoBuffer: Buffer, -): Promise { - // Créée seulement maintenant : en attente pendant l'envoi du Reel, le - // planificateur aurait pu la publier une seconde fois. - let scheduledStoryId: string | undefined; - try { - ({ id: scheduledStoryId } = await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "story", - scheduledAt: new Date(), - })); - const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer); - await storage.updateScheduledPost(scheduledStoryId, { - publishedAt: new Date(), - externalPostId: storyId, - }); - return undefined; - } catch (error) { - const message = error instanceof Error ? error.message : "Erreur inconnue"; - console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error); - if (scheduledStoryId) { - // Marquée comme traitée pour que le planificateur ne la republie pas en boucle - await storage.updateScheduledPost(scheduledStoryId, { - publishedAt: new Date(), - error: message, - }).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e)); - } - return message; - } -} - -/** Message d'erreur lisible lorsque toutes les publications ont échoué. */ -export function describePublishFailure(results: PagePublishResult[]): string { - const details = results.map((r) => r.error).filter(Boolean).join(" ; "); - return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`; -} diff --git a/server/services/reels/queue.ts b/server/services/reels/queue.ts deleted file mode 100644 index 680536e..0000000 --- a/server/services/reels/queue.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * File d'attente persistante des rendus de Reels. - * - * L'ancienne file comptait les posts « processing » pour décider de lancer un - * rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours - * et bloquait tous les Reels suivants. Ici, chaque job est une ligne de - * `reel_jobs` : - * - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut - * tout double traitement, même avec plusieurs instances ; - * - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs - * dont le cœur ne bat plus sont remis en file (ou échouent après - * MAX_ATTEMPTS tentatives). - */ - -import { and, eq, sql } from "drizzle-orm"; -import { db } from "../../db"; -import { posts, reelJobs, type ReelJob } from "@shared/schema"; -import type { ReelJobKind, ReelJobStatus } from "@shared/reel"; - -const HEARTBEAT_INTERVAL_MS = 15_000; -/** Au-delà, un job « processing » est considéré comme orphelin. */ -const STALE_AFTER_MS = 2 * 60_000; -const POLL_INTERVAL_MS = 10_000; -const MAX_ATTEMPTS = 2; - -/** Outils fournis à un handler pour rendre compte de son avancement. */ -export interface JobContext { - job: ReelJob; - progress(progress: number, step?: string): Promise; -} - -export type JobHandler = (ctx: JobContext) => Promise; - -const handlers = new Map(); -let running = false; -let started = false; -let pollTimer: NodeJS.Timeout | null = null; - -export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void { - handlers.set(kind, handler); -} - -/** Ajoute un job à la file et réveille le worker. */ -export async function enqueueReelJob(input: { - kind: ReelJobKind; - userId: string; - postId?: string; - params: unknown; -}): Promise { - const [job] = await db - .insert(reelJobs) - .values({ - kind: input.kind, - userId: input.userId, - postId: input.postId, - params: input.params, - }) - .returning(); - - if (job.postId) { - await syncPost(job.postId, "pending", 0); - } - - kick(); - return job; -} - -export async function getReelJob(id: string): Promise { - const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id)); - return job; -} - -/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */ -export async function countActiveReelJobs(): Promise { - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(reelJobs) - .where(sql`${reelJobs.status} IN ('pending', 'processing')`); - return row?.count ?? 0; -} - -/** - * Démarre le worker : récupère les jobs orphelins puis traite la file. - * Idempotent. - */ -export async function startReelWorker(): Promise { - if (started) return; - started = true; - - try { - await recoverStaleJobs(); - await failLegacyStuckPosts(); - } catch (error) { - console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error); - } - - console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`); - pollTimer = setInterval(kick, POLL_INTERVAL_MS); - pollTimer.unref(); - kick(); -} - -/** Relance la boucle si elle est à l'arrêt. */ -function kick(): void { - if (!started || running) return; - running = true; - drain() - .catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error)) - .finally(() => { - running = false; - }); -} - -async function drain(): Promise { - for (;;) { - const job = await claimNextJob(); - if (!job) return; - await runJob(job); - } -} - -async function claimNextJob(): Promise { - const result = await db.execute<{ id: string }>(sql` - UPDATE reel_jobs - SET status = 'processing', - attempts = attempts + 1, - heartbeat_at = now(), - updated_at = now(), - error = NULL - WHERE id = ( - SELECT id FROM reel_jobs - WHERE status = 'pending' - ORDER BY created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - RETURNING id - `); - const id = result.rows[0]?.id; - return id ? getReelJob(id) : undefined; -} - -async function runJob(job: ReelJob): Promise { - const handler = handlers.get(job.kind as ReelJobKind); - console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`); - - const heartbeat = setInterval(() => { - db.update(reelJobs) - .set({ heartbeatAt: new Date() }) - .where(eq(reelJobs.id, job.id)) - .catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error)); - }, HEARTBEAT_INTERVAL_MS); - - const ctx: JobContext = { - job, - async progress(progress, step) { - await updateJob(job, { progress, step }); - }, - }; - - try { - if (!handler) { - throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`); - } - if (job.postId) { - await syncPost(job.postId, "processing", 0); - } - const result = await handler(ctx); - await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null }); - console.log(`✅ [ReelQueue] Job ${job.id} terminé`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error); - await updateJob(job, { status: "failed", error: message }).catch((e) => - console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e), - ); - } finally { - clearInterval(heartbeat); - } -} - -async function updateJob( - job: ReelJob, - patch: { - status?: ReelJobStatus; - progress?: number; - step?: string; - result?: unknown; - error?: string; - }, -): Promise { - await db - .update(reelJobs) - .set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() }) - .where(eq(reelJobs.id, job.id)); - - if (job.postId) { - const status = patch.status ?? "processing"; - const progress = patch.status === "failed" ? 0 : patch.progress; - await syncPost(job.postId, status, progress, patch.error); - } -} - -/** - * Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les - * colonnes `generation_*` du post. - */ -async function syncPost( - postId: string, - status: ReelJobStatus, - progress?: number, - error?: string, -): Promise { - const patch: Partial = { - generationStatus: status, - updatedAt: new Date(), - }; - if (progress !== undefined) patch.generationProgress = progress; - if (error !== undefined) patch.generationError = error; - if (status === "failed") patch.status = "failed"; - await db.update(posts).set(patch).where(eq(posts.id, postId)); -} - -/** - * Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les - * fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber - * le serveur ne doit pas le faire tomber en boucle. - */ -async function recoverStaleJobs(): Promise { - const staleBefore = new Date(Date.now() - STALE_AFTER_MS); - const stale = await db - .select() - .from(reelJobs) - .where( - and( - eq(reelJobs.status, "processing"), - sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`, - ), - ); - - for (const job of stale) { - if (job.attempts >= MAX_ATTEMPTS) { - console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`); - await updateJob(job, { - status: "failed", - error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).", - }); - } else { - console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`); - await db - .update(reelJobs) - .set({ status: "pending", step: null, progress: 0, updatedAt: new Date() }) - .where(eq(reelJobs.id, job.id)); - if (job.postId) await syncPost(job.postId, "pending", 0); - } - } -} - -/** - * Posts laissés « processing »/« pending » par l'ancienne file (avant - * reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage - * des Reels en cours. On les marque en échec pour que l'utilisateur relance. - */ -async function failLegacyStuckPosts(): Promise { - const result = await db.execute(sql` - UPDATE posts - SET generation_status = 'failed', - generation_progress = 0, - generation_error = 'Génération interrompue, veuillez relancer le Reel.', - status = 'failed', - updated_at = now() - WHERE generation_status IN ('processing', 'pending') - AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id) - `); - if (result.rowCount) { - console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`); - } -} diff --git a/server/services/reels/remotionAssets.test.ts b/server/services/reels/remotionAssets.test.ts deleted file mode 100644 index 8038a4f..0000000 --- a/server/services/reels/remotionAssets.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toDataUrl } from "./remotionAssets"; - -describe("toDataUrl", () => { - it("refuse un chemin qui sort du dossier uploads", async () => { - await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/); - }); - - it("laisse les URL externes telles quelles", async () => { - await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png"); - }); -}); diff --git a/server/services/reels/remotionAssets.ts b/server/services/reels/remotionAssets.ts deleted file mode 100644 index f12ef34..0000000 --- a/server/services/reels/remotionAssets.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Ressources fournies aux compositions Remotion pendant un rendu serveur. - */ - -import fs from "fs"; -import path from "path"; - -/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */ -export const REMOTION_TEMP_DIR = - process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp"); - -/** - * URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont - * lus côté Node par le moteur de rendu : l'adresse locale suffit. - */ -export function localHttpUrl(url: string): string { - if (!url.startsWith("/")) return url; - const port = process.env.PORT || "5555"; - return `http://localhost:${port}${url}`; -} - -/** URL servie d'un fichier du dossier temporaire. */ -export function tempFileUrl(absolutePath: string): string { - const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/"); - return localHttpUrl(`/uploads/temp/${relative}`); -} - -const MIME_BY_EXT: Record = { - png: "image/png", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", -}; - -/** - * Convertit un chemin local /uploads/... en data URL : les images sont - * affichées par Chromium, qui ne lit pas http://localhost de façon fiable - * dans Docker. Refuse tout chemin sortant du dossier uploads. - */ -export async function toDataUrl(relativeUrl: string): Promise { - if (!relativeUrl.startsWith("/uploads/")) return relativeUrl; - const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep; - const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep; - const filePath = relativeUrl.startsWith("/uploads/temp/") - ? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length)) - : path.resolve(process.cwd(), "." + relativeUrl); - if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) { - throw new Error(`Chemin d'image refusé : ${relativeUrl}`); - } - try { - const buffer = await fs.promises.readFile(filePath); - const ext = path.extname(filePath).slice(1).toLowerCase(); - return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`; - } catch { - console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`); - return relativeUrl; - } -} diff --git a/server/services/reels/remotionRenderer.ts b/server/services/reels/remotionRenderer.ts deleted file mode 100644 index 484d496..0000000 --- a/server/services/reels/remotionRenderer.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis - * en cache, réglages d'encodage des réseaux sociaux, progression. - */ - -import os from "os"; -import path from "path"; -import { bundle } from "@remotion/bundler"; -import { renderMedia, selectComposition } from "@remotion/renderer"; - -let bundleCache: Promise | null = null; - -/** Bundle des compositions, construit une fois par processus. */ -export function getRemotionBundle(): Promise { - if (!bundleCache) { - // process.cwd() = /app dans Docker, racine du projet en dev - const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts"); - console.log("📦 [Remotion] Bundle de", entryPoint); - bundleCache = bundle({ - entryPoint, - // Mêmes alias que Vite : les compositions importent @shared/* - webpackOverride: (config) => ({ - ...config, - resolve: { - ...config.resolve, - alias: { - ...(config.resolve?.alias ?? {}), - "@shared": path.resolve(process.cwd(), "shared"), - }, - }, - }), - }).catch((error) => { - bundleCache = null; // réessayer au prochain rendu - throw error; - }); - } - return bundleCache; -} - -/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */ -function renderConcurrency(): number { - const configured = Number(process.env.RENDER_CONCURRENCY); - if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); - return Math.max(1, Math.floor(os.cpus().length / 2)); -} - -export async function renderReelComposition(options: { - compositionId: "ReelVideo"; - inputProps: Record; - outputLocation: string; - onProgress?: (ratio: number) => void; -}): Promise { - const serveUrl = await getRemotionBundle(); - const composition = await selectComposition({ - serveUrl, - id: options.compositionId, - inputProps: options.inputProps, - }); - - const startedAt = Date.now(); - await renderMedia({ - composition, - serveUrl, - inputProps: options.inputProps, - outputLocation: options.outputLocation, - codec: "h264", - // Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok - crf: 18, - x264Preset: "medium", - pixelFormat: "yuv420p", - colorSpace: "bt709", - audioCodec: "aac", - audioBitrate: "192k", - concurrency: renderConcurrency(), - onProgress: ({ progress }) => options.onProgress?.(progress), - // Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu - timeoutInMilliseconds: 60_000, - onBrowserLog: (log) => { - if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300)); - }, - }); - console.log( - `🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`, - ); -} diff --git a/server/services/reels/videoPipeline.ts b/server/services/reels/videoPipeline.ts deleted file mode 100644 index 7bce929..0000000 --- a/server/services/reels/videoPipeline.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication. - * Exécuté par la file `reel_jobs` (voir queue.ts). - * - * Deux moteurs : - * - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR, - * stabilisation) et la piste son finale, Remotion compose les sous-titres - * animés, le logo et l'effet de fin — le même rendu que l'aperçu ; - * - « ffmpeg » : tout est fait par le service Python (sous-titres ASS), - * plus rapide, conservé en secours (REEL_RENDERER=ffmpeg). - */ - -import fs from "fs"; -import path from "path"; -import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel"; -import { storage } from "../../storage"; -import { ffmpegService, type ReelRenderOptions } from "../ffmpeg"; -import { resolveInternalUrl } from "../minio"; -import type { JobContext } from "./queue"; -import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets"; -import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish"; -import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets"; -import { renderReelComposition } from "./remotionRenderer"; - -export type ReelRenderer = "remotion" | "ffmpeg"; - -export function configuredRenderer(): ReelRenderer { - return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion"; -} - -export async function runVideoReelJob({ job, progress }: JobContext) { - if (!job.postId) throw new Error("Job vidéo sans post associé"); - const postId = job.postId; - const params = videoReelParamsSchema.parse(job.params); - - await progress(5, "prepare"); - const media = await storage.getMediaById(params.videoMediaId); - if (!media || media.type !== "video") { - throw new Error("Vidéo source introuvable ou invalide"); - } - - const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([ - resolveMusicUrl(params.musicTrackId, params.musicUrl), - resolveLogoPath(), - resolveGeminiApiKey(params.ttsEngine), - resolveQwenTarget(params.ttsEngine), - ]); - - const options: ReelRenderOptions = { - text: params.srtCues ? undefined : params.overlayText, - srtCues: params.srtCues, - musicUrl, - ttsEnabled: params.ttsEnabled, - ttsVoice: params.ttsVoice, - ttsEngine: params.ttsEngine, - ttsStyle: params.ttsStyle, - geminiApiKey, - qwen, - fontSize: params.fontSize, - musicVolume: params.musicVolume, - drawText: params.drawText, - stabilize: params.stabilize, - storeName: params.storeName, - enableEndingEffect: params.enableEndingEffect, - showLogo: params.showLogo, - }; - - const renderer = configuredRenderer(); - const startedAt = Date.now(); - const videoBuffer = - renderer === "remotion" - ? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress) - : await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress); - console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`); - - await progress(88, "store"); - const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`); - await storage.updatePostMedia(postId, [processedMedia.id]); - - await progress(93, "publish"); - const results = await publishReelToPages({ - postId, - pageIds: params.pageIds, - videoBuffer, - description: params.description || params.overlayText || "", - scheduledFor: params.scheduledFor, - alsoStory: params.alsoStory, - }); - - if (!results.some((r) => r.success)) { - throw new Error(describePublishFailure(results)); - } - - return { mediaId: processedMedia.id, renderer, results }; -} - -async function renderWithFfmpeg( - videoUrl: string, - options: ReelRenderOptions, - logoPath: string | undefined, - progress: JobContext["progress"], -): Promise { - await progress(15, "render"); - // Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux - const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false); - const rendered = await ffmpegService.renderReel(videoUrl, { - ...options, - watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined, - }); - return rendered.video; -} - -async function renderWithRemotion( - jobId: string, - videoUrl: string, - options: ReelRenderOptions, - params: VideoReelParams, - logoPath: string | undefined, - progress: JobContext["progress"], -): Promise { - const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`); - try { - // 1. Image recadrée à la durée finale, piste son finale, mots minutés - await progress(10, "voice"); - const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir); - - // 2. Composition : sous-titres animés, logo, effet de fin - await progress(40, "render"); - const outputLocation = path.join(workdir, "reel.mp4"); - let lastReported = 40; - await renderReelComposition({ - compositionId: "ReelVideo", - outputLocation, - inputProps: { - videoUrl: tempFileUrl(prepared.videoPath), - videoDuration: prepared.totalDuration, - totalDuration: prepared.totalDuration, - words: prepared.words, - captionStyle: params.captionStyle, - logoUrl: logoPath ? await toDataUrl(logoPath) : undefined, - showWatermark: params.showLogo, - storeName: params.enableEndingEffect ? params.storeName : undefined, - logoStart: prepared.logoStart, - fadeStart: prepared.fadeStart, - fadeDuration: prepared.fadeDuration, - mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined, - }, - onProgress: (ratio) => { - const pct = 40 + Math.floor(ratio * 45); - if (pct >= lastReported + 5) { - lastReported = pct; - progress(pct, "render").catch(() => { /* non bloquant */ }); - } - }, - }); - return await fs.promises.readFile(outputLocation); - } finally { - await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ }); - } -} diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts deleted file mode 100644 index 73342ad..0000000 --- a/server/services/scheduler.ts +++ /dev/null @@ -1,182 +0,0 @@ -import cron, { ScheduledTask } from "node-cron"; -import { storage } from "../storage"; -import { facebookService } from "./facebook"; -import { tiktokService } from "./tiktok"; -import { minioService as storageService, readMediaBuffer } from "./minio"; - -export class SchedulerService { - private task: ScheduledTask | null = null; - - start() { - // Run every minute to check for pending posts - this.task = cron.schedule("* * * * *", async () => { - await this.processPendingPosts(); - }); - - console.log("Scheduler service started"); - } - - stop() { - if (this.task) { - this.task.stop(); - console.log("Scheduler service stopped"); - } - } - - private async processPendingPosts() { - try { - const pendingPosts = await storage.getPendingScheduledPosts(); - - for (const scheduledPost of pendingPosts) { - try { - // Reject legacy "both" posts - they should have been split at creation - // New posts are automatically split in /api/posts - if (scheduledPost.postType === 'both') { - const errorMsg = 'Legacy postType "both" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts.'; - console.error(`Rejecting and deleting legacy "both" post ${scheduledPost.id}: ${errorMsg}`); - - // Delete the legacy post entirely (don't mark as published - that's misleading) - await storage.deleteScheduledPost(scheduledPost.id); - - // Update parent post status to draft so user can reschedule - await storage.updatePost(scheduledPost.postId, { - status: "draft", - }); - - continue; - } - - await this.publishPost(scheduledPost); - } catch (error) { - console.error(`Error publishing post ${scheduledPost.id}:`, error); - await storage.updateScheduledPost(scheduledPost.id, { - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - } catch (error) { - console.error("Error processing pending posts:", error); - } - } - - private async publishPost(scheduledPost: any) { - const post = await storage.getPost(scheduledPost.postId); - const page = await storage.getSocialPage(scheduledPost.pageId); - - if (!post || !page) { - throw new Error("Post or page not found"); - } - - console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`); - - // Get all media for the post (for multi-photo carousels) - const postMediaList = await storage.getPostMedia(post.id); - const mediaList: any[] = []; - for (const pm of postMediaList) { - const media = await storage.getMediaById(pm.mediaId); - if (media) { - mediaList.push(media); - } - } - - // Validate that story posts have media - // Note: "both" should never reach here since they're split at creation - if (scheduledPost.postType === 'story' && mediaList.length === 0) { - const errorMsg = 'Les stories nécessitent un média. Ce post ne peut pas être publié.'; - console.error(`Deleting invalid story post ${scheduledPost.id} without media: ${errorMsg}`); - - // Delete the invalid scheduled post (don't mark as published - that's misleading) - await storage.deleteScheduledPost(scheduledPost.id); - - // Set post to draft so user can fix and reschedule - await storage.updatePost(post.id, { - status: "draft", - }); - return; - } - - // Publish to Facebook/Instagram/TikTok - if (page.platform === 'facebook') { - const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList); - - // Update the scheduled post as published (clear any previous error) - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - externalPostId, - error: null, - }); - } else if (page.platform === 'tiktok') { - const publishId = await this.publishToTiktok(post, page, mediaList); - - // TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour - // ne pas republier à la minute suivante, et le poller de statut renseignera - // l'identifiant définitif du post. - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - publishId, - publishStatus: 'PROCESSING_UPLOAD', - error: null, - }); - } else if (page.platform === 'instagram') { - // Instagram publishing not yet implemented - console.warn(`Instagram publishing not yet implemented for post ${post.id}`); - await storage.updateScheduledPost(scheduledPost.id, { - error: 'Instagram publishing not yet implemented', - }); - await storage.updatePost(post.id, { - status: "failed", - }); - return; - } else { - throw new Error(`Unsupported platform: ${page.platform}`); - } - - // Update post status - await storage.updatePost(post.id, { - status: "published", - }); - - // Delete local video files only when no other unpublished scheduled posts reference them - for (const mediaItem of mediaList) { - if (mediaItem.type === 'video' && mediaItem.cloudinaryPublicId) { - try { - const remainingScheduled = await storage.getScheduledPostsByPost(post.id); - const hasPending = remainingScheduled.some(sp => !sp.publishedAt); - if (!hasPending) { - await storageService.deleteMedia(mediaItem.cloudinaryPublicId, mediaItem.userId, 'video'); - console.log(`[Scheduler] Deleted local video file for media ${mediaItem.id}`); - } else { - console.log(`[Scheduler] Kept local video file for media ${mediaItem.id} (pending scheduled posts still exist)`); - } - } catch (err) { - console.warn(`[Scheduler] Failed to delete local video file for media ${mediaItem.id}:`, err); - } - } - } - - console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`); - } - - /** - * Envoie la vidéo d'un post planifié sur un compte TikTok. - * TikTok ne sait pas programmer une publication : c'est notre planificateur qui - * déclenche l'envoi à l'heure voulue. - * - * @returns le publish_id à suivre pour connaître le résultat final - */ - private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise { - const videoMedia = mediaList.find(m => m.type === 'video'); - - if (!videoMedia) { - throw new Error( - `Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.` - ); - } - - const videoBuffer = await readMediaBuffer(videoMedia.originalUrl); - - return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content); - } -} - -export const schedulerService = new SchedulerService(); diff --git a/server/services/thumbnail.ts b/server/services/thumbnail.ts deleted file mode 100644 index 445fe71..0000000 --- a/server/services/thumbnail.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Génération de vignettes vidéo. - * - * Les vidéos sources sont supprimées du disque une fois publiées (le - * planificateur libère la place, la purge quotidienne fait le reste), alors que - * la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface - * tente alors de lire un fichier disparu : d'où les « Video thumbnail generation - * failed » et les tuiles vides. - * - * On extrait donc une image au moment de la création du média, et elle reste - * disponible après la disparition de la vidéo. - */ - -import fs from 'fs'; -import path from 'path'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; -import { minioService } from './minio'; - -const execFileAsync = promisify(execFile); - -const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp'); - -/** - * Extrait une image d'une vidéo avec FFmpeg. - * - * @param videoPath chemin du fichier vidéo - * @param outputPath chemin de l'image à écrire - * @param seekTime instant de la capture, en secondes - * @returns true si l'image a bien été écrite - */ -export async function generateVideoThumbnail( - videoPath: string, - outputPath: string, - seekTime: number = 1 -): Promise { - try { - // Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés - await execFileAsync('ffmpeg', [ - '-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath, - ]); - return fs.existsSync(outputPath); - } catch (error) { - console.warn('⚠️ Failed to generate video thumbnail:', error); - return false; - } -} - -/** - * Produit la vignette d'une vidéo et la range dans le stockage public. - * - * Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit - * jamais faire échouer une publication. - * - * @param source chemin du fichier vidéo, ou son contenu en mémoire - * @returns l'URL publique de la vignette, ou null si l'extraction a échoué - */ -export async function createVideoThumbnail(source: string | Buffer): Promise { - const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; - const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`); - - // Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg - let videoPath: string; - let tempVideoPath: string | null = null; - - try { - if (!fs.existsSync(TEMP_DIR)) { - fs.mkdirSync(TEMP_DIR, { recursive: true }); - } - - if (Buffer.isBuffer(source)) { - tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`); - await fs.promises.writeFile(tempVideoPath, source); - videoPath = tempVideoPath; - } else { - videoPath = source; - if (!fs.existsSync(videoPath)) { - console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`); - return null; - } - } - - const ok = await generateVideoThumbnail(videoPath, outputPath); - if (!ok) return null; - - const buffer = await fs.promises.readFile(outputPath); - const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`); - - console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`); - return url; - } catch (error) { - console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error); - return null; - } finally { - for (const file of [outputPath, tempVideoPath]) { - if (file) { - await fs.promises.unlink(file).catch(() => { /* déjà absent */ }); - } - } - } -} diff --git a/server/services/tiktok.ts b/server/services/tiktok.ts deleted file mode 100644 index 246b656..0000000 --- a/server/services/tiktok.ts +++ /dev/null @@ -1,574 +0,0 @@ -/** - * Service de publication TikTok (Content Posting API v2). - * - * Différences structurantes avec Facebook, qui expliquent la forme de ce service : - * - Pas de token collable à la main : chaque compte magasin passe par OAuth. - * L'access token ne vit que 24h et doit être renouvelé avec le refresh token - * (valable 1 an) avant chaque publication. - * - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant - * définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite. - * - `creator_info/query` doit être appelé avant chaque publication pour connaître - * les niveaux de confidentialité réellement autorisés sur le compte. - * - * Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de - * confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du - * compte). Le code s'adapte automatiquement à ce que le compte autorise. - */ - -import type { Request } from 'express'; -import { storage } from '../storage'; -import { resolvePublicBaseUrl } from '../utils/public_url'; -import type { SocialPage } from '@shared/schema'; - -const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/'; -const API_BASE_URL = 'https://open.tiktokapis.com/v2'; - -/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */ -const DEFAULT_SCOPES = 'user.info.basic,video.publish'; - -/** TikTok impose des chunks entre 5 Mo et 64 Mo. */ -const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024; -const CHUNK_SIZE = 32 * 1024 * 1024; - -/** Longueur maximale de la légende d'une vidéo TikTok. */ -const MAX_TITLE_LENGTH = 2200; - -/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */ -const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; - -export interface TiktokCreatorInfo { - creatorAvatarUrl?: string; - creatorUsername?: string; - creatorNickname?: string; - privacyLevelOptions: string[]; - commentDisabled: boolean; - duetDisabled: boolean; - stitchDisabled: boolean; - maxVideoPostDurationSec?: number; -} - -export interface TiktokPublishOptions { - privacyLevel?: string; - disableComment?: boolean; - disableDuet?: boolean; - disableStitch?: boolean; -} - -export interface TiktokPublishStatus { - status: string; - failReason?: string; - postId?: string; -} - -interface TiktokTokenResponse { - access_token: string; - expires_in: number; - open_id: string; - refresh_token: string; - refresh_expires_in: number; - scope: string; - token_type: string; -} - -export class TiktokService { - /** - * Identifiants de l'application développeur TikTok (globaux à l'instance). - * La base prime sur les variables d'environnement. - */ - private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> { - const config = await storage.getTiktokConfig(); - - const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY; - const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET; - - if (!clientKey || !clientSecret) { - throw new Error( - "TikTok n'est pas configuré. Renseignez le client key et le client secret " + - "de votre application TikTok dans les paramètres." - ); - } - - return { clientKey, clientSecret }; - } - - async isConfigured(): Promise { - try { - await this.getCredentials(); - return true; - } catch { - return false; - } - } - - /** - * URI de redirection OAuth. `req` sert de repli quand `APP_URL` n'est pas - * défini : le domaine public est alors lu sur la requête plutôt que remplacé - * par `http://localhost:5555`. - */ - getRedirectUri(req?: Request): string { - if (process.env.TIKTOK_REDIRECT_URI) { - return process.env.TIKTOK_REDIRECT_URI; - } - return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`; - } - - /** - * URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin. - * Chaque magasin répète ce flux : un compte = une ligne dans social_pages. - */ - async buildAuthorizationUrl(state: string, req?: Request): Promise { - const { clientKey } = await this.getCredentials(); - - const params = new URLSearchParams({ - client_key: clientKey, - scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES, - response_type: 'code', - redirect_uri: this.getRedirectUri(req), - state, - }); - - return `${AUTH_BASE_URL}?${params.toString()}`; - } - - /** Échange le code d'autorisation contre un couple access/refresh token. */ - async exchangeCodeForToken(code: string, req?: Request): Promise { - const { clientKey, clientSecret } = await this.getCredentials(); - - return await this.requestToken({ - client_key: clientKey, - client_secret: clientSecret, - // TikTok renvoie le code URL-encodé dans la query string - code: decodeURIComponent(code), - grant_type: 'authorization_code', - redirect_uri: this.getRedirectUri(req), - }); - } - - /** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */ - async refreshAccessToken(refreshToken: string): Promise { - const { clientKey, clientSecret } = await this.getCredentials(); - - return await this.requestToken({ - client_key: clientKey, - client_secret: clientSecret, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }); - } - - private async requestToken(body: Record): Promise { - const response = await fetch(`${API_BASE_URL}/oauth/token/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cache-Control': 'no-cache', - }, - body: new URLSearchParams(body).toString(), - }); - - const raw = await response.text(); - let data: any; - try { - data = JSON.parse(raw); - } catch { - throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`); - } - - // L'endpoint OAuth signale les erreurs avec `error` + `error_description` - if (!response.ok || data.error) { - const description = data.error_description || data.error || raw.slice(0, 200); - throw new Error(`Erreur OAuth TikTok: ${description}`); - } - - if (!data.access_token) { - throw new Error("Réponse TikTok inattendue : aucun access token reçu"); - } - - return data as TiktokTokenResponse; - } - - /** - * Récupère un access token valide pour une page, en le renouvelant si besoin. - * Le nouveau couple de tokens est persisté immédiatement : le refresh token - * TikTok est à usage unique, le perdre déconnecterait le compte. - */ - async getValidAccessToken(page: SocialPage): Promise { - if (page.platform !== 'tiktok') { - throw new Error('Ce service ne gère que les comptes TikTok'); - } - - const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0; - const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now(); - - if (stillValid && page.accessToken) { - return page.accessToken; - } - - if (!page.refreshToken) { - throw new Error( - `Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).` - ); - } - - console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`); - - let tokens: TiktokTokenResponse; - try { - tokens = await this.refreshAccessToken(page.refreshToken); - } catch (error) { - await storage.updateSocialPage(page.id, { - tokenStatus: 'expired', - lastTokenCheck: new Date(), - }); - throw new Error( - `Impossible de renouveler le token TikTok de "${page.pageName}". ` + - `Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})` - ); - } - - await this.persistTokens(page.id, tokens); - - return tokens.access_token; - } - - /** Enregistre un couple de tokens fraîchement obtenu sur une page. */ - async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise { - await storage.updateSocialPage(pageId, { - accessToken: tokens.access_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - refreshToken: tokens.refresh_token, - refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000), - scopes: tokens.scope, - tokenStatus: 'valid', - lastTokenCheck: new Date(), - }); - } - - /** Profil public du compte, utilisé pour nommer la page à la connexion. */ - async getUserInfo(accessToken: string): Promise<{ - openId: string; - displayName: string; - avatarUrl?: string; - }> { - const params = new URLSearchParams({ - fields: 'open_id,display_name,avatar_url', - }); - - const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - const data = await this.parseApiResponse(response, 'récupération du profil'); - const user = data.user || {}; - - return { - openId: user.open_id, - displayName: user.display_name || 'Compte TikTok', - avatarUrl: user.avatar_url, - }; - } - - /** - * Options réellement autorisées sur le compte (confidentialité, commentaires, - * duo, stitch, durée max). TikTok impose cet appel avant chaque publication. - */ - async getCreatorInfo(page: SocialPage): Promise { - return await this.queryCreatorInfo(await this.getValidAccessToken(page)); - } - - /** - * Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque - * renouvellement, donc une même opération ne doit déclencher qu'un seul refresh. - */ - private async queryCreatorInfo(accessToken: string): Promise { - const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - }); - - const data = await this.parseApiResponse(response, 'récupération des options du compte'); - - return { - creatorAvatarUrl: data.creator_avatar_url, - creatorUsername: data.creator_username, - creatorNickname: data.creator_nickname, - privacyLevelOptions: data.privacy_level_options || [], - commentDisabled: !!data.comment_disabled, - duetDisabled: !!data.duet_disabled, - stitchDisabled: !!data.stitch_disabled, - maxVideoPostDurationSec: data.max_video_post_duration_sec, - }; - } - - /** - * Publie une vidéo sur un compte TikTok en trois temps : - * 1. init — déclare la taille et le découpage, récupère publish_id + upload_url - * 2. upload — envoie les données binaires par chunks - * 3. (plus tard) status/fetch — le poller récupère l'identifiant du post - * - * On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette - * seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok - * et que les médias soient publiquement accessibles. - * - * @returns le publish_id à suivre pour connaître le résultat final - */ - async publishVideoFromBuffer( - page: SocialPage, - videoBuffer: Buffer, - caption?: string, - options: TiktokPublishOptions = {} - ): Promise { - if (page.platform !== 'tiktok') { - throw new Error('Ce service ne gère que les comptes TikTok'); - } - - // Un seul renouvellement de token pour toute la publication - const accessToken = await this.getValidAccessToken(page); - const creatorInfo = await this.queryCreatorInfo(accessToken); - const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName); - - const videoSize = videoBuffer.length; - const { chunkSize, totalChunkCount } = this.computeChunking(videoSize); - - console.log('🎬 [TikTok] Publication en cours:', { - compte: page.pageName, - taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`, - chunks: totalChunkCount, - confidentialite: privacyLevel, - }); - - // ── Étape 1 : init ──────────────────────────────────────────────────────── - const initBody = { - post_info: { - title: (caption || '').slice(0, MAX_TITLE_LENGTH), - privacy_level: privacyLevel, - // Une option désactivée côté compte doit obligatoirement être déclarée désactivée - disable_comment: creatorInfo.commentDisabled || !!options.disableComment, - disable_duet: creatorInfo.duetDisabled || !!options.disableDuet, - disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch, - }, - source_info: { - source: 'FILE_UPLOAD', - video_size: videoSize, - chunk_size: chunkSize, - total_chunk_count: totalChunkCount, - }, - }; - - const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify(initBody), - }); - - const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication'); - const publishId: string = initData.publish_id; - const uploadUrl: string = initData.upload_url; - - if (!publishId || !uploadUrl) { - throw new Error("TikTok n'a pas renvoyé d'identifiant de publication"); - } - - console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`); - - // ── Étape 2 : upload des chunks ─────────────────────────────────────────── - for (let index = 0; index < totalChunkCount; index++) { - const start = index * chunkSize; - // Le dernier chunk absorbe le reliquat - const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1; - const chunk = videoBuffer.subarray(start, end + 1); - - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'video/mp4', - 'Content-Length': chunk.length.toString(), - 'Content-Range': `bytes ${start}-${end}/${videoSize}`, - }, - body: new Blob([chunk], { type: 'video/mp4' }), - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error( - `Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` + - `(${uploadResponse.status}): ${errorText.slice(0, 200)}` - ); - } - - console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`); - } - - console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`); - - return publishId; - } - - /** Interroge TikTok sur l'avancement d'une publication. */ - async getPublishStatus(page: SocialPage, publishId: string): Promise { - const accessToken = await this.getValidAccessToken(page); - - const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify({ publish_id: publishId }), - }); - - const data = await this.parseApiResponse(response, 'suivi de la publication'); - - // Le nom du champ comporte bien une faute de frappe côté API TikTok - const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || []; - - return { - status: data.status, - failReason: data.fail_reason, - postId: postIds.length > 0 ? postIds[0] : undefined, - }; - } - - /** - * Met à jour les publications TikTok encore en cours de traitement. - * Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post - * (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo. - */ - async syncPendingPublications(): Promise { - const pending = await storage.getScheduledPostsAwaitingPublishStatus(); - - if (pending.length === 0) { - return; - } - - console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`); - - for (const scheduledPost of pending) { - try { - const page = await storage.getSocialPage(scheduledPost.pageId); - if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) { - continue; - } - - const status = await this.getPublishStatus(page, scheduledPost.publishId); - - if (status.status === 'PUBLISH_COMPLETE') { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: status.status, - // À défaut d'ID public (compte privé), on garde le publish_id comme référence - externalPostId: status.postId || scheduledPost.publishId, - error: null, - }); - console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`); - continue; - } - - if (status.status === 'FAILED') { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: 'FAILED', - error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`, - }); - console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`); - continue; - } - - // Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API - const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0; - if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: 'FAILED', - error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`, - }); - console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`); - continue; - } - - await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status }); - } catch (error) { - console.error( - `⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`, - error instanceof Error ? error.message : error - ); - } - } - } - - /** - * Choisit un niveau de confidentialité réellement autorisé sur le compte. - * Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt - * que d'échouer, en le signalant dans les logs. - */ - private resolvePrivacyLevel( - creatorInfo: TiktokCreatorInfo, - requested: string | undefined, - pageName: string - ): string { - const options = creatorInfo.privacyLevelOptions; - - if (options.length === 0) { - throw new Error( - `Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` + - `Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.` - ); - } - - const wanted = requested || 'PUBLIC_TO_EVERYONE'; - if (options.includes(wanted)) { - return wanted; - } - - const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0]; - console.warn( - `⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` + - `Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` + - `les vidéos restent privées.` - ); - return fallback; - } - - /** - * Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo, - * le dernier absorbant le reliquat (règle imposée par TikTok). - */ - private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } { - if (videoSize <= MAX_SINGLE_CHUNK_SIZE) { - return { chunkSize: videoSize, totalChunkCount: 1 }; - } - return { - chunkSize: CHUNK_SIZE, - totalChunkCount: Math.floor(videoSize / CHUNK_SIZE), - }; - } - - /** - * Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès : - * il ne suffit pas de regarder le statut HTTP. - */ - private async parseApiResponse(response: Response, context: string): Promise { - const raw = await response.text(); - - let payload: any; - try { - payload = JSON.parse(raw); - } catch { - throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`); - } - - const errorCode = payload?.error?.code; - if (!response.ok || (errorCode && errorCode !== 'ok')) { - const message = payload?.error?.message || raw.slice(0, 200); - throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`); - } - - return payload.data || {}; - } -} - -export const tiktokService = new TiktokService(); diff --git a/server/services/token_manager.ts b/server/services/token_manager.ts deleted file mode 100644 index 82dd90a..0000000 --- a/server/services/token_manager.ts +++ /dev/null @@ -1,358 +0,0 @@ -// crypto removed, using shared encryption utility -import { db } from '../db'; -import { socialPages } from '../../shared/schema'; -import { eq } from 'drizzle-orm'; -import { GraphAPIClient } from '../utils/graph_client'; -import type { SocialPage } from '../../shared/schema'; - -import { encrypt, decrypt } from '../utils/encryption'; -import { facebookOAuthService, FacebookAuthError } from './facebook_oauth'; - -/** - * Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé - * sans attendre : il reste largement le temps de réessayer les jours suivants - * si Facebook est indisponible au moment du contrôle. - */ -const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000; - -/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */ -const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; - -export interface TokenCheckResult { - pageId: string; - pageName: string; - status: 'valid' | 'expiring' | 'expired' | 'error'; - /** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */ - renewed: boolean; - error?: string; -} - -/** - * Service responsible for managing Facebook Page Token lifecycle - * - Encryption at rest - * - Automated refreshing of expiring tokens - */ -export class TokenManager { - - /** - * Encrypts a text using shared encryption utility - */ - static encrypt(text: string): string { - return encrypt(text); - } - - /** - * Decrypts a text using shared encryption utility - */ - static decrypt(text: string): string { - return decrypt(text); - } - - /** - * Checks all active pages for expiring tokens and refreshes them if needed. - */ - static async checkAndRefreshTokens(): Promise { - console.log('[TokenManager] Starting daily token check...'); - const results: TokenCheckResult[] = []; - - try { - // Fetch active pages - const pages = await db.query.socialPages.findMany({ - where: eq(socialPages.isActive, "true") - }); - - for (const page of pages) { - try { - results.push(await this.processPageToken(page)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error); - await this.persistStatus(page.id, 'error', message); - results.push({ - pageId: page.id, - pageName: page.pageName, - status: 'error', - renewed: false, - error: message, - }); - } - } - - console.log('[TokenManager] Finished token check.'); - } catch (e) { - console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e); - } - - return results; - } - - /** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */ - static async checkPage(pageId: string): Promise { - const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId)); - if (!page) { - throw new Error('Page introuvable'); - } - return this.processPageToken(page); - } - - private static async processPageToken(page: SocialPage): Promise { - // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure - // que 24h et se renouvelle avec le refresh token, pas via la Graph API. - if (page.platform === 'tiktok') { - return await this.processTiktokToken(page); - } - - console.log(`[TokenManager] Checking page: ${page.pageName}`); - - const accessToken = this.readToken(page.accessToken); - - // Sans application Facebook configurée, on ne peut ni interroger - // /debug_token ni régénérer quoi que ce soit : on se rabat sur le - // contrôle historique (le jeton répond-il encore ?). - if (!(await facebookOAuthService.isConfigured())) { - return this.legacyCheck(page, accessToken); - } - - let info; - try { - info = await facebookOAuthService.inspectToken(accessToken); - } catch (error) { - // Facebook injoignable : on ne dégrade pas le statut sur un incident - // réseau, sinon toute l'UI passerait au rouge le temps d'une panne. - const message = error instanceof Error ? error.message : String(error); - console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`); - return this.legacyCheck(page, accessToken); - } - - const expiresAt = info.expiresAt; - const expiringSoon = - expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS; - - if (info.isValid && !expiringSoon) { - // Le jeton de page tient : on en profite pour prolonger le jeton - // utilisateur s'il approche de son échéance, car c'est lui qui - // permettra le prochain renouvellement. - await this.extendUserTokenIfNeeded(page); - await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt }); - return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false }; - } - - const reason = info.isValid - ? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}` - : info.error || 'Le jeton a été invalidé par Facebook'; - console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`); - - // Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le - // jeton utilisateur longue durée au moment de la connexion OAuth. - try { - const renewed = await this.renewFromUserToken(page); - if (renewed) { - console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`); - return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true }; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`); - await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, { - tokenExpiresAt: expiresAt, - }); - return { - pageId: page.id, - pageName: page.pageName, - status: info.isValid ? 'expiring' : 'expired', - renewed: false, - error: message, - }; - } - - // Pas de jeton utilisateur en base : la page a été ajoutée à la main, - // seule une reconnexion peut la remettre d'aplomb. - const manual = - `${reason}. Reconnectez la page via « Connecter des pages Facebook » ` + - 'pour activer le renouvellement automatique.'; - const status = info.isValid ? 'expiring' : 'expired'; - await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt }); - return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual }; - } - - /** - * Régénère le jeton de page à partir du jeton utilisateur longue durée. - * Renvoie false quand la page n'a pas de jeton utilisateur enregistré. - */ - private static async renewFromUserToken(page: SocialPage): Promise { - if (!page.userAccessToken) return false; - - const storedUserToken = this.readToken(page.userAccessToken); - - // On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour - // ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle. - // Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne. - let userToken = storedUserToken; - let userTokenExpiresAt = page.userTokenExpiresAt ?? null; - try { - const extended = await facebookOAuthService.extendUserToken(storedUserToken); - userToken = extended.accessToken; - userTokenExpiresAt = extended.expiresAt; - } catch (error) { - console.warn( - `[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`, - error instanceof Error ? error.message : error - ); - } - - const targets = await facebookOAuthService.listPages(userToken); - // Une page Instagram est publiée avec le jeton de la page Facebook à - // laquelle son compte professionnel est rattaché. - const match = targets.find( - (target) => target.id === page.pageId || target.instagram?.id === page.pageId - ); - - if (!match) { - throw new FacebookAuthError( - `La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` + - 'Reconnectez-la avec un compte administrateur de cette page.' - ); - } - - // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire - // pas : `expiresAt` reste donc généralement nul. - let pageTokenExpiresAt: Date | null = null; - try { - pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt; - } catch { - // L'inspection est un confort : le jeton fraîchement émis est bon. - } - - await db.update(socialPages) - .set({ - accessToken: match.accessToken, - tokenExpiresAt: pageTokenExpiresAt, - userAccessToken: encrypt(userToken), - userTokenExpiresAt, - followersCount: match.followersCount || page.followersCount, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - }) - .where(eq(socialPages.id, page.id)); - - return true; - } - - /** - * Prolonge le jeton utilisateur quand son échéance approche, même si le - * jeton de page se porte bien : c'est ce qui évite de perdre la capacité de - * renouveler le jour où le jeton de page est révoqué. - */ - private static async extendUserTokenIfNeeded(page: SocialPage): Promise { - if (!page.userAccessToken) return; - - const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null; - if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return; - - try { - const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken)); - await db.update(socialPages) - .set({ - userAccessToken: encrypt(extended.accessToken), - userTokenExpiresAt: extended.expiresAt, - }) - .where(eq(socialPages.id, page.id)); - console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`); - } catch (error) { - console.warn( - `[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`, - error instanceof Error ? error.message : error - ); - } - } - - /** - * Contrôle historique, utilisé quand l'application Facebook n'est pas - * configurée : on sait seulement si le jeton répond encore. - */ - private static async legacyCheck(page: SocialPage, accessToken: string): Promise { - const isValid = await this.verifyToken(accessToken); - - if (!isValid) { - const error = - "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " + - 'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.'; - await this.persistStatus(page.id, 'expired', error); - return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error }; - } - - // Le jeton répond : reste l'échéance déclarée à l'ajout de la page. - const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null; - const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid'; - await this.persistStatus(page.id, status, null); - return { pageId: page.id, pageName: page.pageName, status, renewed: false }; - } - - /** - * Vérifie qu'un compte TikTok est toujours utilisable. - * On ne teste pas l'access token (il est renouvelé à la demande avant chaque - * publication) mais le refresh token, dont l'expiration déconnecterait le compte. - */ - private static async processTiktokToken(page: SocialPage): Promise { - console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`); - - const refreshExpiresAt = page.refreshTokenExpiresAt - ? new Date(page.refreshTokenExpiresAt).getTime() - : 0; - - let status: "valid" | "expiring" | "expired" = "valid"; - let error: string | null = null; - - if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) { - status = "expired"; - error = "L'autorisation TikTok a expiré : reconnectez le compte."; - console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`); - } else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) { - // Moins de 30 jours de validité restante - status = "expiring"; - } - - await this.persistStatus(page.id, status, error); - return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined }; - } - - private static async persistStatus( - pageId: string, - status: 'valid' | 'expiring' | 'expired' | 'error', - error: string | null, - extra: { tokenExpiresAt?: Date | null } = {} - ): Promise { - await db.update(socialPages) - .set({ - tokenStatus: status, - tokenError: error, - lastTokenCheck: new Date(), - ...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}), - }) - .where(eq(socialPages.id, pageId)); - } - - /** - * Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés - * (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré. - */ - private static readToken(token: string): string { - try { - return this.decrypt(token); - } catch { - return token; - } - } - - private static async verifyToken(token: string): Promise { - try { - // Simple validation check: Get 'me' (page profile) - await GraphAPIClient.get('me', { accessToken: token }); - return true; - } catch (error) { - console.error('[TokenManager] Token verification failed:', error); - return false; - } - } -} diff --git a/server/services/ttsSync.test.ts b/server/services/ttsSync.test.ts deleted file mode 100644 index 143e7b1..0000000 --- a/server/services/ttsSync.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { estimateVoiceTiming } from "./ttsSync"; - -describe("estimateVoiceTiming", () => { - it("ignore hashtags et liens dans le décompte", () => { - expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2); - }); - - it("prévient quand la voix sera trop longue", () => { - const timing = estimateVoiceTiming("mot ".repeat(200)); - expect(timing.isHealthy).toBe(false); - expect(timing.warnings[0]).toMatch(/Texte long/); - }); -}); diff --git a/server/services/ttsSync.ts b/server/services/ttsSync.ts deleted file mode 100644 index 33a85a5..0000000 --- a/server/services/ttsSync.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Estimation de la durée de lecture d'un texte, affichée pendant la saisie. - * - * Auparavant, chaque pause de frappe générait une voix complète pour la - * mesurer (un appel Gemini facturé à chaque fois). Le minutage réel des mots - * vient désormais de la voix au moment du rendu ; ici, une estimation suffit. - */ - -/** Débit moyen d'une voix de synthèse française, en mots par seconde. */ -const WORDS_PER_SECOND = 2.6; -/** Au-delà, la voix dépasse la durée confortable d'un Reel. */ -const MAX_COMFORTABLE_SECONDS = 45; - -export interface SyncTiming { - wordDuration: number; - audioDuration: number; - wordCount: number; - punctuationPause: number; - isHealthy: boolean; - warnings: string[]; -} - -export function estimateVoiceTiming(text: string): SyncTiming { - const words = text - .replace(/#[\wÀ-ÿ]+/g, '') - .replace(/https?:\/\/\S+/g, '') - .split(/\s+/) - .filter((w) => /[A-Za-z0-9À-ÿ]/.test(w)); - const pauses = (text.match(/[.!?;:]/g) ?? []).length; - const punctuationPause = 0.35; - const audioDuration = words.length / WORDS_PER_SECOND + pauses * punctuationPause; - - const warnings: string[] = []; - if (audioDuration > MAX_COMFORTABLE_SECONDS) { - warnings.push(`Texte long : environ ${Math.round(audioDuration)} s de voix. Visez moins de ${MAX_COMFORTABLE_SECONDS} s.`); - } - if (words.length > 0 && words.length < 4) { - warnings.push('Texte très court : la voix ne durera que quelques secondes.'); - } - - return { - wordDuration: words.length ? audioDuration / words.length : 0, - audioDuration, - wordCount: words.length, - punctuationPause, - isHealthy: warnings.length === 0, - warnings, - }; -} diff --git a/server/services/twemoji-parser.d.ts b/server/services/twemoji-parser.d.ts deleted file mode 100644 index 4d8821b..0000000 --- a/server/services/twemoji-parser.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module 'twemoji-parser' { - export interface EmojiEntity { - text: string; - url: string; - indices: [number, number]; - type: string; - } - - export function parse(text: string, options?: { buildUrl?: (codePoint: string, assetType: string) => string }): EmojiEntity[]; - export function toCodePoints(unicodeSurrogates: string): string[]; -} diff --git a/server/src/main.rs b/server/src/main.rs index c551b82..300237a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,5 @@ +// Quelques aides (accès aux données, réponses des services) sont gardées +// complètes même si toutes ne sont pas encore appelées. #![allow(dead_code)] //! SocialFlow — serveur HTTP en Rust. //! diff --git a/server/storage.ts b/server/storage.ts deleted file mode 100644 index eab12a1..0000000 --- a/server/storage.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { - users, - socialPages, - media, - posts, - reelJobs, - postMedia, - scheduledPosts, - aiGenerations, - cloudinaryConfig, - openrouterConfig, - userPagePermissions, - type User, - type InsertUser, - type SocialPage, - type InsertSocialPage, - type Media, - type InsertMedia, - type Post, - type InsertPost, - type PostMedia, - type ScheduledPost, - type InsertScheduledPost, - type AiGeneration, - type InsertAiGeneration, - type CloudinaryConfig, - type InsertCloudinaryConfig, - type OpenrouterConfig, - type InsertOpenrouterConfig, - type UserPagePermission, - type InsertUserPagePermission, - musicFavorites, - type MusicFavorite, - type InsertMusicFavorite, - audioTracks, - type AudioTrack, - type InsertAudioTrack, - appConfig, - type AppConfig, - tiktokConfig, - type TiktokConfig, - type InsertTiktokConfig, - facebookConfig, - type FacebookConfig, - type InsertFacebookConfig, -} from "@shared/schema"; -import { db } from "./db"; -import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm"; -import { encrypt, decrypt, isEncrypted } from "./utils/encryption"; - -/** - * Statuts de publication asynchrone (TikTok) qui ne bougeront plus : inutile de - * continuer à interroger l'API une fois l'un d'eux atteint. - */ -export const TERMINAL_PUBLISH_STATUSES: string[] = [ - 'PUBLISH_COMPLETE', - 'FAILED', - // Vidéo déposée dans la boîte de réception du créateur : elle attend une action - // manuelle dans TikTok et n'évoluera plus côté API. - 'SEND_TO_USER_INBOX', -]; - -/** - * Déchiffre les tokens d'une page avant de la rendre au reste de l'application. - * Les services (Facebook, TikTok) manipulent toujours des tokens en clair. - */ -function decryptPageTokens(page: SocialPage): SocialPage { - return { - ...page, - accessToken: decrypt(page.accessToken), - refreshToken: page.refreshToken ? decrypt(page.refreshToken) : page.refreshToken, - }; -} - -/** Bornes temporelles utilisées pour les comparaisons du tableau de bord. */ -export interface DashboardStatsRange { - yesterdayStart: Date; - yesterdayEnd: Date; - lastMonth: Date; -} - -export interface DashboardStats { - scheduledPosts: number; - scheduledPostsLastMonth: number; - connectedPages: number; - aiTextsGenerated: number; - aiTextsYesterday: number; - mediaStored: number; -} - -/** - * Colonnes renvoyées pour une publication planifiée. - * - * On ne sélectionne pas la ligne `social_pages` entière : elle porte les jetons - * d'accès Facebook et TikTok, qui partaient jusqu'ici dans chaque réponse HTTP - * — y compris vers le navigateur, qui n'en fait rien. - */ -const SCHEDULED_POST_COLUMNS = { - ...getTableColumns(scheduledPosts), - post: { - id: posts.id, - userId: posts.userId, - content: posts.content, - status: posts.status, - scheduledFor: posts.scheduledFor, - aiGenerated: posts.aiGenerated, - generationStatus: posts.generationStatus, - generationProgress: posts.generationProgress, - createdAt: posts.createdAt, - }, - page: { - id: socialPages.id, - pageId: socialPages.pageId, - pageName: socialPages.pageName, - platform: socialPages.platform, - avatarUrl: socialPages.avatarUrl, - }, -}; - -export interface IStorage { - // Users - getUser(id: string): Promise; - getUserByUsername(username: string): Promise; - getAllUsers(): Promise; - getDashboardStats(userId: string, options: DashboardStatsRange): Promise; - createUser(user: InsertUser): Promise; - updateUser(id: string, user: Partial): Promise; - deleteUser(id: string): Promise; - - // Social Pages - getSocialPages(userId: string): Promise; - getAllSocialPages(): Promise; - getSocialPage(id: string): Promise; - createSocialPage(page: InsertSocialPage): Promise; - updateSocialPage(id: string, page: Partial): Promise; - deleteSocialPage(id: string): Promise; - - // Media - getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise; - getMediaByIds(ids: string[]): Promise; - getMediaCount(userId: string): Promise; - getMediaById(id: string): Promise; - createMedia(media: InsertMedia): Promise; - deleteMedia(id: string): Promise; - getPostMedia(postId: string): Promise; - - // Posts - getPosts(userId: string): Promise; - getPost(id: string): Promise; - getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>; - getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>; - getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]>; - createPost(post: InsertPost): Promise; - updatePost(id: string, post: Partial): Promise; - updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise; - deletePost(id: string): Promise; - updatePostMedia(postId: string, mediaIds: string[]): Promise; - - // Scheduled Posts - getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise; - getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise; - getScheduledPost(id: string): Promise; - getScheduledPostsByPost(postId: string): Promise; - createScheduledPost(scheduledPost: InsertScheduledPost): Promise; - updateScheduledPost(id: string, scheduledPost: Partial): Promise; - deleteScheduledPost(id: string): Promise; - getPendingScheduledPosts(): Promise; - getScheduledPostsAwaitingPublishStatus(): Promise; - - // AI Generations - getAiGenerations(userId: string): Promise; - createAiGeneration(generation: InsertAiGeneration): Promise; - - // TikTok Config - getTiktokConfig(): Promise; - upsertTiktokConfig(config: Partial): Promise; - getFacebookConfig(): Promise; - upsertFacebookConfig(config: Partial): Promise; - - // Cloudinary Config - getCloudinaryConfig(): Promise; - createCloudinaryConfig(config: InsertCloudinaryConfig): Promise; - updateCloudinaryConfig(config: Partial): Promise; - - // OpenRouter Config - getOpenrouterConfig(userId: string): Promise; - getAnyOpenrouterConfig(): Promise; - createOpenrouterConfig(config: InsertOpenrouterConfig): Promise; - updateOpenrouterConfig(userId: string, config: Partial): Promise; - - // User Page Permissions - getUserPagePermissions(userId: string): Promise; - getPagePermissions(pageId: string): Promise; - createPagePermission(permission: InsertUserPagePermission): Promise; - deletePagePermission(userId: string, pageId: string): Promise; - deleteAllUserPagePermissions(userId: string): Promise; - getUserAccessiblePages(userId: string): Promise; - - // Music Favorites - getMusicFavorites(userId: string): Promise; - addMusicFavorite(favorite: InsertMusicFavorite): Promise; - removeMusicFavorite(userId: string, trackId: string): Promise; - isMusicFavorite(userId: string, trackId: string): Promise; - - // Audio Tracks - getAudioTracks(): Promise; - // Audio Tracks - getAudioTrack(id: string): Promise; - createAudioTrack(track: InsertAudioTrack): Promise; - deleteAudioTrack(id: string): Promise; - - // App Config - getAppConfig(): Promise; - upsertAppConfig(data: Partial>): Promise; -} - -export class DatabaseStorage implements IStorage { - // Users - async getUser(id: string): Promise { - const [user] = await db.select().from(users).where(eq(users.id, id)); - return user || undefined; - } - - async getUserByUsername(username: string): Promise { - const [user] = await db.select().from(users).where(eq(users.username, username)); - return user || undefined; - } - - async getAllUsers(): Promise { - return await db.select().from(users); - } - - async createUser(insertUser: InsertUser): Promise { - const [user] = await db.insert(users).values(insertUser).returning(); - return user; - } - - async updateUser(id: string, updateData: Partial): Promise { - const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning(); - return updated; - } - - async deleteUser(id: string): Promise { - await db.delete(users).where(eq(users.id, id)); - } - - /** - * Compteurs du tableau de bord. - * - * Comptés en SQL : les charger en mémoire revenait à lire quatre tables - * entières — tous les posts, toutes les pages, tous les médias, toutes les - * générations — pour n'en garder que le nombre de lignes. - */ - async getDashboardStats(userId: string, range: DashboardStatsRange): Promise { - const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([ - db - .select({ - scheduled: sql`count(*) filter (where ${posts.status} = 'scheduled')`, - scheduledLastMonth: sql`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`, - }) - .from(posts) - .where(eq(posts.userId, userId)), - db - .select({ - total: sql`count(*)`, - yesterday: sql`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`, - }) - .from(aiGenerations) - .where(eq(aiGenerations.userId, userId)), - db.select({ total: sql`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)), - db.select({ total: sql`count(*)` }).from(media).where(eq(media.userId, userId)), - ]); - - return { - scheduledPosts: Number(postCounts[0]?.scheduled ?? 0), - scheduledPostsLastMonth: Number(postCounts[0]?.scheduledLastMonth ?? 0), - connectedPages: Number(pageCount[0]?.total ?? 0), - aiTextsGenerated: Number(generationCounts[0]?.total ?? 0), - aiTextsYesterday: Number(generationCounts[0]?.yesterday ?? 0), - mediaStored: Number(mediaCount[0]?.total ?? 0), - }; - } - - // Social Pages - async getSocialPages(userId: string): Promise { - const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId)); - return pages.map(page => decryptPageTokens(page)); - } - - /** - * Toutes les pages de l'instance, pour les vues d'administration. - * Remplace un `getAllUsers()` suivi d'une requête par utilisateur. - */ - async getAllSocialPages(): Promise { - const pages = await db.select().from(socialPages); - return pages.map(page => decryptPageTokens(page)); - } - - async getSocialPage(id: string): Promise { - const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); - return page ? decryptPageTokens(page) : undefined; - } - - async createSocialPage(page: InsertSocialPage): Promise { - // Chiffrer les tokens avant stockage - const encryptedPage = { - ...page, - accessToken: encrypt(page.accessToken), - ...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}), - }; - const [newPage] = await db.insert(socialPages).values(encryptedPage).returning(); - return decryptPageTokens(newPage); - } - - async updateSocialPage(id: string, page: Partial): Promise { - // Chiffrer les tokens présents dans la mise à jour - const updateData: Partial = { ...page }; - if (page.accessToken) { - updateData.accessToken = encrypt(page.accessToken); - } - if (page.refreshToken) { - updateData.refreshToken = encrypt(page.refreshToken); - } - const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning(); - return decryptPageTokens(updated); - } - - async deleteSocialPage(id: string): Promise { - await db.delete(socialPages).where(eq(socialPages.id, id)); - } - - // Media - /** - * Médias d'un utilisateur, du plus récent au plus ancien. - * `limit` sert aux écrans qui n'en affichent qu'une poignée : sans lui, la - * médiathèque entière transitait pour en montrer cinq. - */ - async getMedia(userId: string, options: { limit?: number; offset?: number } = {}): Promise { - let query = db - .select() - .from(media) - .where(eq(media.userId, userId)) - .orderBy(desc(media.createdAt)) - .$dynamic(); - - if (options.limit !== undefined) query = query.limit(options.limit); - if (options.offset !== undefined) query = query.offset(options.offset); - - return await query; - } - - async getMediaByIds(ids: string[]): Promise { - if (ids.length === 0) return []; - return await db.select().from(media).where(inArray(media.id, ids)); - } - - /** Total de médias, pour que la pagination sache où elle s'arrête. */ - async getMediaCount(userId: string): Promise { - const [row] = await db - .select({ total: sql`count(*)` }) - .from(media) - .where(eq(media.userId, userId)); - return Number(row?.total ?? 0); - } - - async getMediaById(id: string): Promise { - const [mediaItem] = await db.select().from(media).where(eq(media.id, id)); - return mediaItem || undefined; - } - - async createMedia(mediaItem: InsertMedia): Promise { - const [newMedia] = await db.insert(media).values(mediaItem).returning(); - return newMedia; - } - - async deleteMedia(id: string): Promise { - await db.delete(media).where(eq(media.id, id)); - } - - async getPostMedia(postId: string): Promise { - return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder)); - } - - // Posts - async getPosts(userId: string): Promise { - return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt)); - } - - async getPost(id: string): Promise { - const [post] = await db.select().from(posts).where(eq(posts.id, id)); - return post || undefined; - } - - async getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined> { - const post = await this.getPost(id); - if (!post) return undefined; - - const postMediaLinks = await this.getPostMedia(id); - - // Une seule requête pour tous les médias, puis remise dans l'ordre - // d'affichage : une requête par média rendait le coût proportionnel au - // nombre de visuels du post. - const byId = new Map( - (await this.getMediaByIds(postMediaLinks.map(link => link.mediaId))).map(item => [item.id, item]) - ); - const mediaItems = postMediaLinks - .map(link => byId.get(link.mediaId)) - .filter((item): item is Media => item !== undefined); - - return { post, media: mediaItems }; - } - - async createPost(post: InsertPost): Promise { - const [newPost] = await db.insert(posts).values(post).returning(); - return newPost; - } - - async updatePost(id: string, post: Partial): Promise { - const [updated] = await db.update(posts).set({ ...post, updatedAt: new Date() }).where(eq(posts.id, id)).returning(); - return updated; - } - - async deletePost(id: string): Promise { - await db.delete(posts).where(eq(posts.id, id)); - } - - async updatePostGenerationStatus( - id: string, - status: string, - progress: number, - error?: string - ): Promise { - const updateData: Record = { - generationStatus: status, - generationProgress: progress, - updatedAt: new Date(), - }; - if (error !== undefined) { - updateData.generationError = error; - } - const [updated] = await db - .update(posts) - .set(updateData) - .where(eq(posts.id, id)) - .returning(); - return updated; - } - - async updatePostMedia(postId: string, mediaIds: string[]): Promise { - await db.delete(postMedia).where(eq(postMedia.postId, postId)); - - if (mediaIds.length > 0) { - const postMediaEntries = mediaIds.map((mediaId, index) => ({ - postId, - mediaId, - displayOrder: index, - })); - await db.insert(postMedia).values(postMediaEntries); - } - } - - // Scheduled Posts - async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise { - return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate); - } - - async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise { - if (pageIds.length === 0) { - return []; - } - return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate); - } - - /** - * Publications planifiées, avec le post et la page auxquels elles se rapportent. - * - * La période est filtrée en SQL : la charger entière pour la filtrer en - * mémoire revenait à lire tout l'historique à chaque affichage du calendrier. - */ - private async queryScheduledPosts( - scope: SQL, - startDate?: Date, - endDate?: Date - ): Promise { - const conditions: SQL[] = [scope]; - if (startDate) conditions.push(gte(scheduledPosts.scheduledAt, startDate)); - if (endDate) conditions.push(lte(scheduledPosts.scheduledAt, endDate)); - - const rows = await db - .select(SCHEDULED_POST_COLUMNS) - .from(scheduledPosts) - .innerJoin(posts, eq(scheduledPosts.postId, posts.id)) - .leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id)) - .where(conditions.length === 1 ? conditions[0] : and(...conditions)) - .orderBy(desc(scheduledPosts.scheduledAt)); - - // La jointure externe renvoie un objet aux champs nuls quand la page a été - // supprimée ; les appelants attendent `page: null` dans ce cas. - return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null })); - } - - async getScheduledPost(id: string): Promise { - const [scheduledPost] = await db.select().from(scheduledPosts).where(eq(scheduledPosts.id, id)); - return scheduledPost || undefined; - } - - async getScheduledPostsByPost(postId: string): Promise { - return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId)); - } - - async createScheduledPost(scheduledPost: InsertScheduledPost): Promise { - const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning(); - return newScheduledPost; - } - - async updateScheduledPost(id: string, scheduledPost: Partial): Promise { - const [updated] = await db.update(scheduledPosts).set(scheduledPost).where(eq(scheduledPosts.id, id)).returning(); - return updated; - } - - async deleteScheduledPost(id: string): Promise { - await db.delete(scheduledPosts).where(eq(scheduledPosts.id, id)); - } - - async getPendingScheduledPosts(): Promise { - const now = new Date(); - const results = await db - .select() - .from(scheduledPosts) - .where( - and( - isNull(scheduledPosts.publishedAt), - lte(scheduledPosts.scheduledAt, now) - ) - ); - return results; - } - - /** - * Publications déjà envoyées à TikTok dont le statut final n'est pas encore - * connu (l'API répond de façon asynchrone via un publish_id). - */ - async getScheduledPostsAwaitingPublishStatus(): Promise { - return await db - .select() - .from(scheduledPosts) - .where( - and( - isNotNull(scheduledPosts.publishId), - notInArray(scheduledPosts.publishStatus, TERMINAL_PUBLISH_STATUSES) - ) - ); - } - - /** - * Reels dont la génération est en cours, plus ceux qui ont échoué dans les - * dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape - * courante du rendu. Filtré en SQL : la route est interrogée toutes les - * trois secondes pendant un rendu. - */ - async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const rows = await db - .select() - .from(posts) - .where( - and( - eq(posts.userId, userId), - or( - inArray(posts.generationStatus, ['processing', 'pending']), - and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)), - ), - ) - ) - .orderBy(desc(posts.createdAt)); - if (rows.length === 0) return []; - - const jobs = await db - .select({ postId: reelJobs.postId, step: reelJobs.step }) - .from(reelJobs) - .where(inArray(reelJobs.postId, rows.map((p) => p.id))); - const stepByPost = new Map(jobs.map((j) => [j.postId, j.step])); - return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null })); - } - - /** - * Reels vidéo en file ou en montage : leurs publications planifiées - * n'existent qu'à la fin du rendu, l'agenda les lit donc ici. - */ - async getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]> { - return await db - .select({ - id: reelJobs.id, - postId: reelJobs.postId, - createdAt: reelJobs.createdAt, - params: reelJobs.params, - content: posts.content, - }) - .from(reelJobs) - .leftJoin(posts, eq(reelJobs.postId, posts.id)) - .where(and(eq(reelJobs.kind, 'video'), inArray(reelJobs.status, ['pending', 'processing']))); - } - - // AI Generations - async getAiGenerations(userId: string): Promise { - return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt)); - } - - async createAiGeneration(generation: InsertAiGeneration): Promise { - const [newGeneration] = await db.insert(aiGenerations).values(generation).returning(); - return newGeneration; - } - - // Cloudinary Config - async getCloudinaryConfig(): Promise { - const [config] = await db.select().from(cloudinaryConfig).limit(1); - return config || undefined; - } - - async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise { - const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning(); - return newConfig; - } - - async updateCloudinaryConfig(config: Partial): Promise { - const existingConfig = await this.getCloudinaryConfig(); - if (!existingConfig) throw new Error("Cloudinary config not initialized"); - - const [updated] = await db.update(cloudinaryConfig) - .set({ ...config, updatedAt: new Date() }) - .where(eq(cloudinaryConfig.id, existingConfig.id)) - .returning(); - return updated; - } - - // OpenRouter Config - async getOpenrouterConfig(userId: string): Promise { - const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId)); - return config || undefined; - } - - async getAnyOpenrouterConfig(): Promise { - const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1); - return config || undefined; - } - - async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise { - const [newConfig] = await db.insert(openrouterConfig).values(config).returning(); - return newConfig; - } - - async updateOpenrouterConfig(userId: string, config: Partial): Promise { - const [updated] = await db.update(openrouterConfig) - .set({ ...config, updatedAt: new Date() }) - .where(eq(openrouterConfig.userId, userId)) - .returning(); - return updated; - } - - // User Page Permissions - async getUserPagePermissions(userId: string): Promise { - return await db.select().from(userPagePermissions).where(eq(userPagePermissions.userId, userId)); - } - - async getPagePermissions(pageId: string): Promise { - return await db.select().from(userPagePermissions).where(eq(userPagePermissions.pageId, pageId)); - } - - async createPagePermission(permission: InsertUserPagePermission): Promise { - const [newPermission] = await db.insert(userPagePermissions).values(permission).returning(); - return newPermission; - } - - async deletePagePermission(userId: string, pageId: string): Promise { - await db.delete(userPagePermissions).where( - and( - eq(userPagePermissions.userId, userId), - eq(userPagePermissions.pageId, pageId) - ) - ); - } - - async deleteAllUserPagePermissions(userId: string): Promise { - await db.delete(userPagePermissions).where(eq(userPagePermissions.userId, userId)); - } - - async getUserAccessiblePages(userId: string): Promise { - const permissions = await db - .select() - .from(userPagePermissions) - .innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id)) - .where(eq(userPagePermissions.userId, userId)); - - return permissions.map(p => p.social_pages); - } - - // Music Favorites - async getMusicFavorites(userId: string): Promise { - return db.select().from(musicFavorites) - .where(eq(musicFavorites.userId, userId)) - .orderBy(desc(musicFavorites.createdAt)); - } - - async addMusicFavorite(favorite: InsertMusicFavorite): Promise { - const [created] = await db.insert(musicFavorites).values(favorite).returning(); - return created; - } - - async removeMusicFavorite(userId: string, trackId: string): Promise { - await db.delete(musicFavorites).where( - and( - eq(musicFavorites.userId, userId), - eq(musicFavorites.trackId, trackId) - ) - ); - } - - async isMusicFavorite(userId: string, trackId: string): Promise { - const [fav] = await db.select().from(musicFavorites).where( - and( - eq(musicFavorites.userId, userId), - eq(musicFavorites.trackId, trackId) - ) - ); - return !!fav; - } - - // Audio Tracks - async getAudioTracks(): Promise { - return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt)); - } - - async getAudioTrack(id: string): Promise { - const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id)); - return track || undefined; - } - - async createAudioTrack(track: InsertAudioTrack): Promise { - const [newTrack] = await db.insert(audioTracks).values(track).returning(); - return newTrack; - } - - async deleteAudioTrack(id: string): Promise { - await db.delete(audioTracks).where(eq(audioTracks.id, id)); - } - - // App Config - async getAppConfig(): Promise { - const [config] = await db.select().from(appConfig).limit(1); - return config || undefined; - } - - // Facebook Config (application développeur, globale à l'instance) - async getFacebookConfig(): Promise { - const [config] = await db.select().from(facebookConfig).limit(1); - if (!config) return undefined; - return { ...config, appSecret: decrypt(config.appSecret) }; - } - - async upsertFacebookConfig(data: Partial): Promise { - const values: Partial = { ...data }; - if (data.appSecret) { - values.appSecret = encrypt(data.appSecret); - } - - const [existing] = await db.select().from(facebookConfig).limit(1); - if (existing) { - const [updated] = await db.update(facebookConfig) - .set({ ...values, updatedAt: new Date() }) - .where(eq(facebookConfig.id, existing.id)) - .returning(); - return { ...updated, appSecret: decrypt(updated.appSecret) }; - } - - if (!values.appId || !values.appSecret) { - throw new Error("L'App ID et l'App Secret Facebook sont requis"); - } - - const [created] = await db.insert(facebookConfig) - .values({ appId: values.appId, appSecret: values.appSecret }) - .returning(); - return { ...created, appSecret: decrypt(created.appSecret) }; - } - - // TikTok Config - async getTiktokConfig(): Promise { - const [config] = await db.select().from(tiktokConfig).limit(1); - if (!config) return undefined; - return { ...config, clientSecret: decrypt(config.clientSecret) }; - } - - async upsertTiktokConfig(data: Partial): Promise { - const values: Partial = { ...data }; - if (data.clientSecret) { - values.clientSecret = encrypt(data.clientSecret); - } - - const [existing] = await db.select().from(tiktokConfig).limit(1); - if (existing) { - const [updated] = await db.update(tiktokConfig) - .set({ ...values, updatedAt: new Date() }) - .where(eq(tiktokConfig.id, existing.id)) - .returning(); - return { ...updated, clientSecret: decrypt(updated.clientSecret) }; - } - - if (!values.clientKey || !values.clientSecret) { - throw new Error("Le client key et le client secret TikTok sont requis"); - } - - const [created] = await db.insert(tiktokConfig) - .values({ clientKey: values.clientKey, clientSecret: values.clientSecret }) - .returning(); - return { ...created, clientSecret: decrypt(created.clientSecret) }; - } - - async upsertAppConfig(data: Partial>): Promise { - const existing = await this.getAppConfig(); - if (existing) { - const [updated] = await db.update(appConfig) - .set({ ...data, updatedAt: new Date() }) - .where(eq(appConfig.id, existing.id)) - .returning(); - return updated; - } - const [created] = await db.insert(appConfig).values(data).returning(); - return created; - } -} - -export const storage = new DatabaseStorage(); diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts deleted file mode 100644 index c44a86a..0000000 --- a/server/utils/encryption.ts +++ /dev/null @@ -1,152 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; - -// Chemin du fichier de clé - utilise le volume Docker si disponible, sinon le répertoire courant -const KEY_DIR = fs.existsSync('/app/.encryption-key-data') - ? '/app/.encryption-key-data' - : process.cwd(); -const KEY_FILE_PATH = path.join(KEY_DIR, '.encryption-key'); - -// Cache de la clé pour éviter de lire le fichier à chaque appel -let cachedKey: Buffer | null = null; -let keyInitialized = false; - -/** - * Génère une clé de chiffrement aléatoire et la sauvegarde dans un fichier. - * @returns La clé générée - */ -function generateAndSaveKey(): Buffer { - const randomKey = crypto.randomBytes(32).toString('hex'); - try { - fs.writeFileSync(KEY_FILE_PATH, randomKey, { mode: 0o600 }); // Permissions restrictives - console.log('🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement'); - } catch (error) { - console.warn('⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage.'); - } - return crypto.scryptSync(randomKey, 'socialflow-salt', 32); -} - -/** - * Charge la clé depuis le fichier persistant si elle existe. - * @returns La clé ou null si non trouvée - */ -function loadKeyFromFile(): Buffer | null { - try { - if (fs.existsSync(KEY_FILE_PATH)) { - const savedKey = fs.readFileSync(KEY_FILE_PATH, 'utf8').trim(); - if (savedKey) { - console.log('🔐 Clé de chiffrement chargée depuis le fichier persistant'); - return crypto.scryptSync(savedKey, 'socialflow-salt', 32); - } - } - } catch (error) { - console.warn('⚠️ Impossible de lire le fichier de clé de chiffrement'); - } - return null; -} - -/** - * Récupère la clé de chiffrement. - * Priorité: 1) Variable d'environnement, 2) Fichier persistant, 3) Génération auto - */ -function getEncryptionKey(): Buffer { - // Retourner la clé en cache si déjà initialisée - if (keyInitialized && cachedKey) { - return cachedKey; - } - - // 1. Priorité à la variable d'environnement - const envKey = process.env.ENCRYPTION_KEY; - if (envKey) { - cachedKey = crypto.scryptSync(envKey, 'socialflow-salt', 32); - keyInitialized = true; - return cachedKey; - } - - // 2. Essayer de charger depuis le fichier persistant - const fileKey = loadKeyFromFile(); - if (fileKey) { - cachedKey = fileKey; - keyInitialized = true; - return cachedKey; - } - - // 3. Générer automatiquement une nouvelle clé et la sauvegarder - cachedKey = generateAndSaveKey(); - keyInitialized = true; - return cachedKey; -} - -/** - * Chiffre une chaîne de texte avec AES-256-GCM. - * @param text - Le texte à chiffrer - * @returns Le texte chiffré au format: iv:authTag:encrypted (hex) - */ -export function encrypt(text: string): string { - const key = getEncryptionKey(); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - - // Format: iv:authTag:encrypted - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; -} - -/** - * Déchiffre une chaîne chiffrée avec AES-256-GCM. - * @param encryptedText - Le texte chiffré au format iv:authTag:encrypted - * @returns Le texte déchiffré - */ -export function decrypt(encryptedText: string): string { - // Si le texte ne contient pas le format attendu, retourner tel quel - // (pour la rétrocompatibilité avec les tokens non chiffrés) - if (!encryptedText.includes(':')) { - return encryptedText; - } - - const key = getEncryptionKey(); - const parts = encryptedText.split(':'); - - if (parts.length !== 3) { - // Format invalide, retourner tel quel (rétrocompatibilité) - return encryptedText; - } - - const [ivHex, authTagHex, encrypted] = parts; - - try { - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - // Si le déchiffrement échoue, retourner tel quel (rétrocompatibilité) - console.warn('⚠️ Échec du déchiffrement, token probablement non chiffré'); - return encryptedText; - } -} - -/** - * Vérifie si un texte est déjà chiffré (format iv:authTag:encrypted). - * @param text - Le texte à vérifier - * @returns true si le texte semble être chiffré - */ -export function isEncrypted(text: string): boolean { - if (!text.includes(':')) return false; - const parts = text.split(':'); - if (parts.length !== 3) return false; - // Vérifier que les parties ressemblent à du hex - return parts.every(part => /^[a-f0-9]+$/i.test(part)); -} diff --git a/server/utils/graph_client.ts b/server/utils/graph_client.ts deleted file mode 100644 index 7bbc047..0000000 --- a/server/utils/graph_client.ts +++ /dev/null @@ -1,94 +0,0 @@ - - -export class FacebookApiError extends Error { - constructor( - public message: string, - public status: number, - public code: number, - public fbTraceId: string - ) { - super(message); - this.name = 'FacebookApiError'; - } -} - -interface GraphRequestConfig { - params?: Record; - accessToken?: string; -} - -const GRAPH_API_VERSION = 'v19.0'; -const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`; - -export class GraphAPIClient { - - static async get(endpoint: string, config: GraphRequestConfig): Promise { - const url = this.buildUrl(endpoint, config); - console.log(`[GraphAPI] GET ${endpoint}`); - - // In node-fetch v2 (which is typically used in CJS/TS mixes unless v3 ESM), basic fetch - // If project uses v3, imports might differ. Assuming standard fetch is available or polyfilled. - // socialflow package.json has "@types/node-fetch": "^2.6.13" but also "node-fetch" isn't explicitly in dependencies? - // Wait, package.json has "node-fetch" missing in dependencies list! - // But it has "@types/node-fetch". - // "canvas" is there. "express" is there. - // I need to check if fetch is globally available (Node 18+) or if I need to use `axios` or add `node-fetch`. - // Package.json has: "type": "module". Node 20.16.11 is used in parsing. - // Node 18+ has native fetch. I should use `global.fetch` or just `fetch`. - // Explicit import `import fetch from 'node-fetch'` might fail if not installed. - // I will use native fetch. - - // Remove import above if using native. - - const response = await fetch(url.toString()); - return this.handleResponse(response); - } - - static async post(endpoint: string, body: any, config: GraphRequestConfig): Promise { - const url = this.buildUrl(endpoint, config); - console.log(`[GraphAPI] POST ${endpoint}`); - - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - return this.handleResponse(response); - } - - private static buildUrl(endpoint: string, config: GraphRequestConfig): URL { - const url = new URL(`${BASE_URL}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`); - - if (config.accessToken) { - url.searchParams.append('access_token', config.accessToken); - } - - if (config.params) { - Object.entries(config.params).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); - }); - } - - return url; - } - - private static async handleResponse(response: Response): Promise { - const data: any = await response.json(); - - if (!response.ok) { - // Parse FB Error structure - const error = data.error || {}; - throw new FacebookApiError( - error.message || 'Unknown Facebook API Error', - response.status, - error.code || 0, - error.fbtrace_id || '' - ); - } - - return data as T; - } -} diff --git a/server/utils/public_url.ts b/server/utils/public_url.ts deleted file mode 100644 index c721e8b..0000000 --- a/server/utils/public_url.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * URL publique de l'application. - * - * Les flux OAuth (Facebook, TikTok) exigent une URI de redirection *exacte* : - * celle envoyée au moment de l'autorisation doit être identique à celle - * envoyée lors de l'échange du code, et déclarée telle quelle chez le - * fournisseur. Se rabattre en silence sur `http://localhost:5555` quand - * `APP_URL` n'est pas défini produit une URI que Facebook refuse — et - * l'erreur n'apparaît qu'au moment de connecter une page. - * - * On préfère donc, à défaut d'`APP_URL`, reconstruire l'URL à partir de la - * requête entrante : derrière Nginx, les en-têtes `X-Forwarded-Proto` et - * `X-Forwarded-Host` portent le domaine public réel (`trust proxy` est activé - * dans `server/index.ts`). Le repli localhost ne sert plus qu'au développement - * local, hors requête HTTP. - */ - -import type { Request } from 'express'; - -const DEV_FALLBACK = 'http://localhost:5555'; - -/** Premier élément d'un en-tête potentiellement chaîné (« a, b, c »). */ -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(',')[0]?.trim(); - return first || undefined; -} - -/** - * Base publique sans slash final (« https://exemple.fr »). - * `APP_URL` prime toujours ; sinon la requête fait foi. - */ -export function resolvePublicBaseUrl(req?: Request): string { - const configured = process.env.APP_URL?.trim(); - if (configured) { - return configured.replace(/\/$/, ''); - } - - if (req) { - const host = firstHeaderValue(req.get('x-forwarded-host')) || req.get('host'); - if (host) { - const protocol = firstHeaderValue(req.get('x-forwarded-proto')) || req.protocol || 'http'; - return `${protocol}://${host}`; - } - } - - return DEV_FALLBACK; -} diff --git a/server/vite.ts b/server/vite.ts deleted file mode 100644 index 9338c14..0000000 --- a/server/vite.ts +++ /dev/null @@ -1,85 +0,0 @@ -import express, { type Express } from "express"; -import fs from "fs"; -import path from "path"; -import { createServer as createViteServer, createLogger } from "vite"; -import { type Server } from "http"; -import viteConfig from "../vite.config"; -import { nanoid } from "nanoid"; - -const viteLogger = createLogger(); - -export function log(message: string, source = "express") { - const formattedTime = new Date().toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - - console.log(`${formattedTime} [${source}] ${message}`); -} - -export async function setupVite(app: Express, server: Server) { - const serverOptions = { - middlewareMode: true, - hmr: { server }, - allowedHosts: true as const, - }; - - const vite = await createViteServer({ - ...viteConfig, - configFile: false, - customLogger: { - ...viteLogger, - error: (msg, options) => { - viteLogger.error(msg, options); - process.exit(1); - }, - }, - server: serverOptions, - appType: "custom", - }); - - app.use(vite.middlewares); - app.use("*", async (req, res, next) => { - const url = req.originalUrl; - - try { - const clientTemplate = path.resolve( - import.meta.dirname, - "..", - "client", - "index.html", - ); - - // always reload the index.html file from disk incase it changes - let template = await fs.promises.readFile(clientTemplate, "utf-8"); - template = template.replace( - `src="/src/main.tsx"`, - `src="/src/main.tsx?v=${nanoid()}"`, - ); - const page = await vite.transformIndexHtml(url, template); - res.status(200).set({ "Content-Type": "text/html" }).end(page); - } catch (e) { - vite.ssrFixStacktrace(e as Error); - next(e); - } - }); -} - -export function serveStatic(app: Express) { - const distPath = path.resolve(import.meta.dirname, "public"); - - if (!fs.existsSync(distPath)) { - throw new Error( - `Could not find the build directory: ${distPath}, make sure to build the client first`, - ); - } - - app.use(express.static(distPath)); - - // fall through to index.html if the file doesn't exist - app.use("*", (_req, res) => { - res.sendFile(path.resolve(distPath, "index.html")); - }); -}