From 516a9034970f4b30041ac9cb61b85e323d60b424 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 6 Apr 2026 09:11:56 -0400 Subject: [PATCH] Refactor audio handling and S3 integration in LMS service - Removed company-specific template rules from template application logic. - Enhanced question generation queries to support both 'imported-mysql' and 'imported-material' sources. - Introduced S3 audio storage functionality, including client setup and audio key generation. - Updated audio response evaluation to store audio files in S3 or fallback to DB. - Added new API routes for asset ingestion and ZIP import in CMS service. - Implemented role-based access control for audio responses in LMS service. - Created a smoke test script for validating audio roles and permissions. - Updated frontend to support course selection in audio evaluations. --- Cargo.lock | 828 +++++++++++++++- scripts/smoke_audio_roles.sh | 242 +++++ services/cms-service/Cargo.toml | 2 + services/cms-service/src/external_handlers.rs | 18 - services/cms-service/src/handlers.rs | 53 +- services/cms-service/src/handlers_assets.rs | 928 +++++++++++++++++- .../src/handlers_test_templates.rs | 105 +- services/cms-service/src/main.rs | 5 + services/lms-service/Cargo.toml | 3 + services/lms-service/src/handlers.rs | 375 ++++++- .../src/app/admin/audio-evaluations/page.tsx | 36 +- web/studio/src/lib/api.ts | 47 + 12 files changed, 2476 insertions(+), 166 deletions(-) create mode 100755 scripts/smoke_audio_roles.sh diff --git a/Cargo.lock b/Cargo.lock index c5379c3..fec3023 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,476 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26bbf46abc608f2dc61fd6cb3b7b0665497cc259a21520151ed98f8b37d2c79" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f92058d22a46adf53ec57a6a96f34447daf02bff52e8fb956c66bcd5c6ac12" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.4", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http 0.62.6", + "aws-smithy-json 0.61.9", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699da1961a289b23842d88fe2984c6ff68735fdf9bdcbc69ceaeb2491c9bf434" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e3a4cb3b124833eafea9afd1a6cc5f8ddf3efefffc6651ef76a03cbc6b4981" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f6ae9b71597dc5fd115d52849d7a5556ad9265885ad3492ea8d73b93bbc46e" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.4", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256 0.11.1", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.63.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" +dependencies = [ + "aws-smithy-http 0.62.6", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4a8a5fe3e4ac7ee871237c340bbce13e982d37543b65700f4419e039f5d78e" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0709f0083aa19b704132684bc26d3c868e06bd428ccc4373b0b55c3e8748a58b" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.12", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd3dfc18c1ce097cf81fced7192731e63809829c6cbf933c1ec47452d08e1aa" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c55e0837e9b8526f49e0b9bfa9ee18ddee70e853f5bc09c5d11ebceddcb0fec" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576b0d6991c9c32bc14fc340582ef148311f924d41815f641a308b5d11e8e7cd" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c50f3cdf47caa8d01f2be4a6663ea02418e892f9bbfd82c7b9a3a37eaccdd3a" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.7" @@ -135,6 +605,12 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base16ct" version = "0.2.0" @@ -159,6 +635,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.1" @@ -230,6 +716,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bzip2" version = "0.4.4" @@ -292,11 +788,22 @@ dependencies = [ "inout", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cms-service" version = "0.1.0" dependencies = [ "anyhow", + "aws-config", + "aws-sdk-s3", "axum", "base64 0.22.1", "bcrypt", @@ -378,6 +885,16 @@ dependencies = [ "libc", ] +[[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" @@ -408,6 +925,19 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc-fast" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" +dependencies = [ + "crc", + "digest", + "rand 0.9.2", + "regex", + "rustversion", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -432,6 +962,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -530,6 +1072,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -580,24 +1132,42 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", + "signature 1.6.4", +] + [[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -606,8 +1176,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -633,23 +1203,43 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct 0.1.1", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff 0.12.1", + "generic-array", + "group 0.12.1", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1 0.3.0", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -707,6 +1297,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.13.1" @@ -796,6 +1396,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -935,13 +1541,24 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] @@ -1190,6 +1807,7 @@ dependencies = [ "futures-util", "http 0.2.12", "hyper 0.14.32", + "log", "rustls 0.21.12", "tokio", "tokio-rustls 0.24.1", @@ -1205,6 +1823,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls 0.23.35", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -1541,6 +2160,8 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" name = "lms-service" version = "0.1.0" dependencies = [ + "aws-config", + "aws-sdk-s3", "axum", "base64 0.22.1", "bcrypt", @@ -1549,6 +2170,7 @@ dependencies = [ "dotenvy", "http 1.4.0", "jsonwebtoken", + "mime_guess", "reqwest 0.12.26", "serde", "serde_json", @@ -1579,6 +2201,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1673,10 +2304,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -1811,7 +2442,7 @@ dependencies = [ "itertools", "log", "oauth2", - "p256", + "p256 0.13.2", "p384", "rand 0.8.5", "rsa", @@ -1860,6 +2491,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -1881,14 +2518,31 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2", +] + [[package]] name = "p256" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "primeorder", "sha2", ] @@ -1899,8 +2553,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "primeorder", "sha2", ] @@ -2020,9 +2674,19 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", ] [[package]] @@ -2031,8 +2695,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -2077,7 +2741,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -2248,6 +2912,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -2337,6 +3007,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2373,10 +3054,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -2421,6 +3102,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2429,6 +3111,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2463,6 +3157,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2529,16 +3224,30 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct 0.1.1", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -2550,7 +3259,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2734,6 +3456,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "2.2.0" @@ -2815,6 +3547,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -2822,7 +3564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -3096,7 +3838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.5.0", ] @@ -3107,7 +3849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -3595,6 +4337,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" @@ -4073,6 +4821,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.1" diff --git a/scripts/smoke_audio_roles.sh b/scripts/smoke_audio_roles.sh new file mode 100755 index 0000000..1b80d77 --- /dev/null +++ b/scripts/smoke_audio_roles.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +DB_CONTAINER="openccb-db" +DB_NAME="openccb_lms" +DB_USER="user" +API_RUNNER_CONTAINER="${API_RUNNER_CONTAINER:-openccb-studio}" +INTERNAL_BASE_URL="${INTERNAL_BASE_URL:-http://experience:3002}" + +JWT_SECRET="${JWT_SECRET:-}" +if [[ -z "$JWT_SECRET" ]]; then + if [[ -f .env ]]; then + JWT_SECRET="$(grep -E '^JWT_SECRET=' .env | head -n1 | cut -d'=' -f2-)" + fi +fi + +if [[ -z "$JWT_SECRET" ]]; then + echo "ERROR: JWT_SECRET not found (env or .env)" + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq is required" + exit 1 +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "ERROR: openssl is required" + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: docker is required" + exit 1 +fi + +b64url() { + openssl base64 -A | tr '+/' '-_' | tr -d '=' +} + +make_jwt() { + local user_id="$1" + local org_id="$2" + local role="$3" + local now exp header payload header_b64 payload_b64 signature + + now="$(date +%s)" + exp="$((now + 86400))" + + header='{"alg":"HS256","typ":"JWT"}' + payload="$(jq -cn \ + --arg sub "$user_id" \ + --arg org "$org_id" \ + --arg role "$role" \ + --argjson exp "$exp" \ + '{sub:$sub,org:$org,exp:$exp,role:$role,course_id:null,token_type:"access"}')" + + header_b64="$(printf '%s' "$header" | b64url)" + payload_b64="$(printf '%s' "$payload" | b64url)" + signature="$(printf '%s' "${header_b64}.${payload_b64}" | openssl dgst -sha256 -hmac "$JWT_SECRET" -binary | b64url)" + + printf '%s.%s.%s' "$header_b64" "$payload_b64" "$signature" +} + +run_sql() { + local sql="$1" + docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -t -A -c "$sql" +} + +check_http() { + local label="$1" + local expected="$2" + local token="$3" + local path="$4" + local method="${5:-GET}" + local body="${6:-}" + + local response + response="$(docker exec \ + -e TARGET_URL="${INTERNAL_BASE_URL}${path}" \ + -e METHOD="$method" \ + -e TOKEN="$token" \ + -e ORG_ID="$ORG_ID" \ + -e REQ_BODY="$body" \ + "$API_RUNNER_CONTAINER" \ + node -e " +const url = process.env.TARGET_URL; +const method = process.env.METHOD || 'GET'; +const token = process.env.TOKEN; +const orgId = process.env.ORG_ID; +const body = process.env.REQ_BODY || ''; + +const headers = { + 'Authorization': 'Bearer ' + token, + 'X-Organization-Id': orgId, +}; + +const init = { method, headers }; +if (body) { + headers['Content-Type'] = 'application/json'; + init.body = body; +} + +fetch(url, init) + .then(async (res) => { + const text = await res.text(); + process.stdout.write(String(res.status) + '\n'); + process.stdout.write(text); + }) + .catch((err) => { + console.error('FETCH_ERROR:' + err.message); + process.exit(2); + }); +")" + + local code + code="$(printf '%s' "$response" | head -n1)" + local response_body + response_body="$(printf '%s' "$response" | tail -n +2)" + + local ok="0" + IFS='|' read -r -a expected_codes <<< "$expected" + for ec in "${expected_codes[@]}"; do + if [[ "$code" == "$ec" ]]; then + ok="1" + break + fi + done + + if [[ "$ok" == "1" ]]; then + echo "PASS [$label] -> HTTP $code" + else + echo "FAIL [$label] -> expected $expected, got $code" + echo "Response body:" + printf '%s\n' "$response_body" + return 1 + fi +} + +cleanup() { + if [[ "${KEEP_FIXTURES:-0}" == "1" ]]; then + echo "KEEP_FIXTURES=1 -> skipping cleanup for debugging" + return + fi + run_sql "DELETE FROM audio_responses WHERE id IN ('${RESP_A_ID}', '${RESP_B_ID}');" >/dev/null || true + run_sql "DELETE FROM course_instructors WHERE course_id IN ('${COURSE_A_ID}', '${COURSE_B_ID}') AND user_id='${INSTRUCTOR_ID}';" >/dev/null || true + run_sql "DELETE FROM lessons WHERE id IN ('${LESSON_A_ID}', '${LESSON_B_ID}');" >/dev/null || true + run_sql "DELETE FROM modules WHERE id IN ('${MODULE_A_ID}', '${MODULE_B_ID}');" >/dev/null || true + run_sql "DELETE FROM courses WHERE id IN ('${COURSE_A_ID}', '${COURSE_B_ID}');" >/dev/null || true + run_sql "DELETE FROM users WHERE id IN ('${ADMIN_ID}', '${INSTRUCTOR_ID}', '${STUDENT_OWNER_ID}', '${STUDENT_OTHER_ID}');" >/dev/null || true +} +trap cleanup EXIT + +ORG_ID="00000000-0000-0000-0000-000000000001" +ADMIN_ID="$(cat /proc/sys/kernel/random/uuid)" +INSTRUCTOR_ID="$(cat /proc/sys/kernel/random/uuid)" +STUDENT_OWNER_ID="$(cat /proc/sys/kernel/random/uuid)" +STUDENT_OTHER_ID="$(cat /proc/sys/kernel/random/uuid)" +COURSE_A_ID="$(cat /proc/sys/kernel/random/uuid)" +COURSE_B_ID="$(cat /proc/sys/kernel/random/uuid)" +MODULE_A_ID="$(cat /proc/sys/kernel/random/uuid)" +MODULE_B_ID="$(cat /proc/sys/kernel/random/uuid)" +LESSON_A_ID="$(cat /proc/sys/kernel/random/uuid)" +LESSON_B_ID="$(cat /proc/sys/kernel/random/uuid)" +BLOCK_A_ID="$(cat /proc/sys/kernel/random/uuid)" +BLOCK_B_ID="$(cat /proc/sys/kernel/random/uuid)" +RESP_A_ID="$(cat /proc/sys/kernel/random/uuid)" +RESP_B_ID="$(cat /proc/sys/kernel/random/uuid)" + +SUFFIX="$(date +%s)" + +echo "Creating temporary LMS fixtures..." +echo "ORG_ID=${ORG_ID}" +echo "RESP_A_ID=${RESP_A_ID}" +echo "RESP_B_ID=${RESP_B_ID}" +run_sql " +INSERT INTO organizations (id, name) +VALUES ('${ORG_ID}', 'OpenCCB Default Org') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO users (id, email, password_hash, full_name, role, organization_id) +VALUES + ('${ADMIN_ID}', 'smoke-admin-${SUFFIX}@local.test', 'x', 'Smoke Admin', 'admin', '${ORG_ID}'), + ('${INSTRUCTOR_ID}', 'smoke-instructor-${SUFFIX}@local.test', 'x', 'Smoke Instructor', 'instructor', '${ORG_ID}'), + ('${STUDENT_OWNER_ID}', 'smoke-student-owner-${SUFFIX}@local.test', 'x', 'Smoke Student Owner', 'student', '${ORG_ID}'), + ('${STUDENT_OTHER_ID}', 'smoke-student-other-${SUFFIX}@local.test', 'x', 'Smoke Student Other', 'student', '${ORG_ID}'); + +INSERT INTO courses (id, title, description, instructor_id, organization_id) +VALUES + ('${COURSE_A_ID}', 'Smoke Course A', 'Course with instructor access', '${INSTRUCTOR_ID}', '${ORG_ID}'), + ('${COURSE_B_ID}', 'Smoke Course B', 'Course without instructor access', '${ADMIN_ID}', '${ORG_ID}'); + +INSERT INTO modules (id, course_id, title, position, organization_id) +VALUES + ('${MODULE_A_ID}', '${COURSE_A_ID}', 'Module A', 1, '${ORG_ID}'), + ('${MODULE_B_ID}', '${COURSE_B_ID}', 'Module B', 1, '${ORG_ID}'); + +INSERT INTO lessons (id, module_id, title, content_type, position, organization_id) +VALUES + ('${LESSON_A_ID}', '${MODULE_A_ID}', 'Lesson A', 'video', 1, '${ORG_ID}'), + ('${LESSON_B_ID}', '${MODULE_B_ID}', 'Lesson B', 'video', 1, '${ORG_ID}'); + +INSERT INTO course_instructors (course_id, user_id, role) +VALUES + ('${COURSE_A_ID}', '${INSTRUCTOR_ID}', 'instructor'); + +INSERT INTO audio_responses ( + id, organization_id, user_id, course_id, lesson_id, block_id, prompt, + transcript, audio_data, ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at, + status, attempt_number, duration_seconds +) +VALUES + ( + '${RESP_A_ID}', '${ORG_ID}', '${STUDENT_OWNER_ID}', '${COURSE_A_ID}', '${LESSON_A_ID}', '${BLOCK_A_ID}', + 'Prompt A', 'Transcript A', convert_to('aGVsbG8=', 'UTF8'), 80, ARRAY['keyword'], 'good', now(), + 'ai_evaluated', 1, 12 + ), + ( + '${RESP_B_ID}', '${ORG_ID}', '${STUDENT_OWNER_ID}', '${COURSE_B_ID}', '${LESSON_B_ID}', '${BLOCK_B_ID}', + 'Prompt B', 'Transcript B', convert_to('aGVsbG8=', 'UTF8'), 75, ARRAY['keyword'], 'pending review', now(), + 'pending', 1, 11 + ); +" >/dev/null + +echo "Generating role tokens..." +ADMIN_TOKEN="$(make_jwt "$ADMIN_ID" "$ORG_ID" "admin")" +INSTRUCTOR_TOKEN="$(make_jwt "$INSTRUCTOR_ID" "$ORG_ID" "instructor")" +OWNER_STUDENT_TOKEN="$(make_jwt "$STUDENT_OWNER_ID" "$ORG_ID" "student")" +OTHER_STUDENT_TOKEN="$(make_jwt "$STUDENT_OTHER_ID" "$ORG_ID" "student")" + +echo "Running role-based smoke checks..." +check_http "admin pending_instructor list" "200" "$ADMIN_TOKEN" "/audio-responses?status=pending_instructor" +check_http "instructor list scoped" "200" "$INSTRUCTOR_TOKEN" "/audio-responses" +check_http "instructor forbidden detail out-of-course" "403|404" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_B_ID}" +check_http "owner student audio access" "200" "$OWNER_STUDENT_TOKEN" "/audio-responses/${RESP_A_ID}/audio" +check_http "other student denied audio access" "403" "$OTHER_STUDENT_TOKEN" "/audio-responses/${RESP_A_ID}/audio" +check_http "instructor evaluate in-course" "200" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_A_ID}/evaluate" "POST" '{"teacher_score":90,"teacher_feedback":"ok"}' +check_http "instructor denied evaluate out-of-course" "403" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_B_ID}/evaluate" "POST" '{"teacher_score":70,"teacher_feedback":"n/a"}' +check_http "admin stats course A" "200" "$ADMIN_TOKEN" "/courses/${COURSE_A_ID}/audio-responses/stats" +check_http "instructor denied stats course B" "403" "$INSTRUCTOR_TOKEN" "/courses/${COURSE_B_ID}/audio-responses/stats" + +echo "All smoke checks passed." diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index 7dc61a8..4021874 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -33,3 +33,5 @@ mime_guess = "2.0" base64 = "0.22.1" regex = "1.11" rand = "0.8" +aws-config = "1" +aws-sdk-s3 = "1" diff --git a/services/cms-service/src/external_handlers.rs b/services/cms-service/src/external_handlers.rs index 32e5085..aebfb89 100644 --- a/services/cms-service/src/external_handlers.rs +++ b/services/cms-service/src/external_handlers.rs @@ -9,14 +9,6 @@ use serde_json::json; use sqlx::PgPool; use uuid::Uuid; -const COMPANY_SPECIFIC_RULES_ORG_ID: &str = "00000000-0000-0000-0000-000000000001"; - -fn uses_company_specific_template_rules(org_id: Uuid) -> bool { - Uuid::parse_str(COMPANY_SPECIFIC_RULES_ORG_ID) - .map(|id| id == org_id) - .unwrap_or(false) -} - async fn validate_api_key(headers: &HeaderMap, pool: &PgPool) -> Result { let api_key = headers .get("X-API-Key") @@ -226,16 +218,6 @@ async fn create_course_lesson_and_apply_template( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if uses_company_specific_template_rules(org_id) { - if template.2 == "CA" && template_questions.len() < 4 { - return Err(StatusCode::BAD_REQUEST); - } - - if template.2 != "CA" && template_questions.len() != 1 { - return Err(StatusCode::BAD_REQUEST); - } - } - let questions_json: Vec = template_questions .iter() .map(|q| { diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 30f10c3..3ba0242 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -733,12 +733,10 @@ pub async fn process_transcription( } let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?; - let filename = url.trim_start_matches("/assets/"); - let file_path = format!("uploads/{}", filename); - // 2. Read file to verify it exists - if !tokio::fs::metadata(&file_path).await.is_ok() { - tracing::error!("File not found: {}", file_path); + // 2. Validate media is reachable (local /assets or absolute URL) + if read_lesson_media_bytes(&url).await.is_err() { + tracing::error!("Media not accessible for transcription: {}", url); return Err(StatusCode::INTERNAL_SERVER_ERROR); } @@ -855,11 +853,9 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), .map_err(|e| format!("Lesson fetch failed: {}", e))?; let url = lesson.content_url.ok_or("No content URL")?; - let filename = url.trim_start_matches("/assets/"); - let file_path = format!("uploads/{}", filename); // 2. Set status to processing ONLY if it's still queued (not cancelled/idle) - tracing::info!("Starting transcription for lesson {} (file: {})", lesson_id, file_path); + tracing::info!("Starting transcription for lesson {} (media: {})", lesson_id, url); let rows_affected = sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1 AND transcription_status = 'queued'") .bind(lesson_id) .execute(&pool) @@ -873,10 +869,11 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), } // 3. Read file - let file_data = tokio::fs::read(&file_path) + let filename_for_whisper = extract_filename_from_content_url(&url); + let file_data = read_lesson_media_bytes(&url) .await .map_err(|e| { - let err = format!("File read failed ({}): {}", file_path, e); + let err = format!("File read failed ({}): {}", url, e); tracing::error!("{}", err); err })?; @@ -889,7 +886,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), // We assume a standard Whisper API (like faster-whisper-server or openai-compatible) let form = reqwest::multipart::Form::new() - .part("file", reqwest::multipart::Part::bytes(file_data).file_name(filename.to_string())) + .part("file", reqwest::multipart::Part::bytes(file_data).file_name(filename_for_whisper)) .text("model", "whisper-1") .text("response_format", "json"); @@ -1094,6 +1091,40 @@ fn format_vtt_timestamp(seconds: f64) -> String { format!("{:02}:{:02}:{:02}.{:03}", hours, mins, secs, millis) } +fn extract_filename_from_content_url(url: &str) -> String { + url.rsplit('/') + .next() + .filter(|v| !v.is_empty()) + .unwrap_or("media.bin") + .to_string() +} + +async fn read_lesson_media_bytes(url: &str) -> Result, String> { + if url.starts_with("http://") || url.starts_with("https://") { + let response = reqwest::Client::new() + .get(url) + .send() + .await + .map_err(|e| format!("HTTP read failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("HTTP read returned status {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("HTTP bytes read failed: {}", e))?; + return Ok(bytes.to_vec()); + } + + let filename = url.trim_start_matches("/assets/"); + let file_path = format!("uploads/{}", filename); + tokio::fs::read(&file_path) + .await + .map_err(|e| format!("Local read failed ({}): {}", file_path, e)) +} + pub async fn summarize_lesson( Org(org_ctx): Org, claims: common::auth::Claims, diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs index f824b4d..10ff733 100644 --- a/services/cms-service/src/handlers_assets.rs +++ b/services/cms-service/src/handlers_assets.rs @@ -3,12 +3,22 @@ use axum::{ extract::{Path, Query, State, Multipart}, http::StatusCode, }; +use aws_config::BehaviorVersion; +use aws_config::meta::region::RegionProviderChain; +use aws_sdk_s3::{ + Client as S3Client, + config::{Credentials, Region}, +}; use common::models::{Asset}; +use common::ai::{self, generate_embedding}; use common::{auth::Claims, middleware::Org}; use serde::{Deserialize, Serialize}; +use serde_json::json; use sqlx::PgPool; use uuid::Uuid; +use std::env; use std::path::Path as StdPath; +use tokio::process::Command; #[derive(Debug, Serialize)] pub struct AssetUploadResponse { @@ -19,6 +29,22 @@ pub struct AssetUploadResponse { pub size_bytes: i64, } +#[derive(Debug, Serialize)] +pub struct AssetRagIngestResponse { + pub asset_id: Uuid, + pub source: String, + pub chunks_ingested: usize, + pub chars_ingested: usize, +} + +#[derive(Debug, Serialize)] +pub struct AssetZipImportResponse { + pub imported_assets: usize, + pub rag_ingested_assets: usize, + pub rag_chunks_ingested: usize, + pub failed_entries: Vec, +} + #[derive(Debug, Deserialize)] pub struct AssetFilters { pub mimetype: Option, @@ -28,6 +54,180 @@ pub struct AssetFilters { pub limit: Option, } +#[derive(Debug, Clone)] +struct S3Settings { + bucket: String, + region: String, + endpoint: Option, + public_base_url: Option, + force_path_style: bool, +} + +fn get_s3_settings() -> Option { + let enabled = env::var("ASSETS_STORAGE") + .unwrap_or_else(|_| "local".to_string()) + .to_lowercase(); + + if enabled != "s3" { + return None; + } + + let bucket = env::var("S3_BUCKET").ok()?; + let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-2".to_string()); + let endpoint = env::var("S3_ENDPOINT").ok().filter(|v| !v.trim().is_empty()); + let public_base_url = env::var("S3_PUBLIC_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()); + let force_path_style = env::var("S3_FORCE_PATH_STYLE") + .map(|v| { + let lower = v.to_lowercase(); + lower == "1" || lower == "true" || lower == "yes" + }) + .unwrap_or(false); + + Some(S3Settings { + bucket, + region, + endpoint, + public_base_url, + force_path_style, + }) +} + +async fn build_s3_client(settings: &S3Settings) -> Result { + let region_provider = RegionProviderChain::first_try(Some(Region::new(settings.region.clone()))) + .or_default_provider(); + + let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region_provider); + + let access_key = env::var("AWS_ACCESS_KEY_ID").ok(); + let secret_key = env::var("AWS_SECRET_ACCESS_KEY").ok(); + if let (Some(ak), Some(sk)) = (access_key, secret_key) { + let creds = Credentials::new(ak, sk, None, None, "env"); + loader = loader.credentials_provider(creds); + } + + let shared_config = loader.load().await; + let mut s3_builder = aws_sdk_s3::config::Builder::from(&shared_config); + if let Some(endpoint) = &settings.endpoint { + s3_builder = s3_builder.endpoint_url(endpoint); + } + if settings.force_path_style { + s3_builder = s3_builder.force_path_style(true); + } + + Ok(S3Client::from_conf(s3_builder.build())) +} + +fn build_s3_object_key(org_id: Uuid, course_id: Option, storage_filename: &str) -> String { + match course_id { + Some(cid) => format!("org/{}/course/{}/assets/{}", org_id, cid, storage_filename), + None => format!("org/{}/shared/assets/{}", org_id, storage_filename), + } +} + +fn build_s3_public_url(settings: &S3Settings, key: &str) -> String { + if let Some(base) = &settings.public_base_url { + return format!("{}/{}", base.trim_end_matches('/'), key); + } + + format!( + "https://{}.s3.{}.amazonaws.com/{}", + settings.bucket, settings.region, key + ) +} + +async fn maybe_push_local_file_to_s3( + local_path: &str, + storage_filename: &str, + mimetype: &str, + org_id: Uuid, + course_id: Option, +) -> Result, (StatusCode, String)> { + let settings = match get_s3_settings() { + Some(s) => s, + None => return Ok(None), + }; + + let bytes = tokio::fs::read(local_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read local file failed: {}", e)))?; + + let client = build_s3_client(&settings).await?; + let key = build_s3_object_key(org_id, course_id, storage_filename); + + client + .put_object() + .bucket(&settings.bucket) + .key(&key) + .content_type(mimetype) + .body(bytes.into()) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 upload failed: {}", e)))?; + + let storage_path = format!("s3://{}/{}", settings.bucket, key); + let public_url = build_s3_public_url(&settings, &key); + Ok(Some((storage_path, public_url))) +} + +async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, String)> { + if let Some((bucket, key)) = parse_s3_storage_path(storage_path) { + let settings = get_s3_settings().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "S3 storage path found but S3 is not configured".to_string(), + ))?; + let client = build_s3_client(&settings).await?; + client + .delete_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 delete failed: {}", e)))?; + return Ok(()); + } + + let _ = tokio::fs::remove_file(storage_path).await; + Ok(()) +} + +fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> { + let without_prefix = path.strip_prefix("s3://")?; + let (bucket, key) = without_prefix.split_once('/')?; + if bucket.is_empty() || key.is_empty() { + return None; + } + Some((bucket, key)) +} + +async fn read_storage_bytes(storage_path: &str) -> Result, (StatusCode, String)> { + if let Some((bucket, key)) = parse_s3_storage_path(storage_path) { + let settings = get_s3_settings().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "S3 storage path found but S3 is not configured".to_string(), + ))?; + let client = build_s3_client(&settings).await?; + let output = client + .get_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 read failed: {}", e)))?; + let data = output + .body + .collect() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 stream read failed: {}", e)))?; + return Ok(data.into_bytes().to_vec()); + } + + tokio::fs::read(storage_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e))) +} + /// POST /api/assets/upload - Subir un archivo a la biblioteca global pub async fn upload_asset( Org(org_ctx): Org, @@ -71,29 +271,71 @@ pub async fn upload_asset( } let asset_id = Uuid::new_v4(); - let extension = StdPath::new(&filename) - .extension() - .and_then(|s| s.to_str()) - .unwrap_or(""); - - let storage_filename = format!("{}.{}", asset_id, extension); - let storage_path = format!("uploads/{}", storage_filename); // Ensure uploads directory exists tokio::fs::create_dir_all("uploads") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Write file - tokio::fs::write(&storage_path, data) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let (storage_filename, storage_path, stored_filename, stored_mimetype) = + if is_flv_media(&filename, &mimetype) { + let temp_storage_filename = format!("{}.flv", asset_id); + let temp_storage_path = format!("uploads/{}", temp_storage_filename); + tokio::fs::write(&temp_storage_path, data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let final_storage_filename = format!("{}.mp4", asset_id); + let final_storage_path = format!("uploads/{}", final_storage_filename); + transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await?; + let _ = tokio::fs::remove_file(&temp_storage_path).await; + + ( + final_storage_filename, + final_storage_path, + replace_extension(&filename, "mp4"), + "video/mp4".to_string(), + ) + } else { + let extension = StdPath::new(&filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let storage_filename = if extension.is_empty() { + asset_id.to_string() + } else { + format!("{}.{}", asset_id, extension) + }; + let storage_path = format!("uploads/{}", storage_filename); + + tokio::fs::write(&storage_path, data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + (storage_filename, storage_path, filename.clone(), mimetype.clone()) + }; let size_bytes = tokio::fs::metadata(&storage_path) .await .map(|m| m.len() as i64) .unwrap_or(0); + let (db_storage_path, asset_url) = if let Some((s3_path, public_url)) = maybe_push_local_file_to_s3( + &storage_path, + &storage_filename, + &stored_mimetype, + org_ctx.id, + course_id, + ) + .await? + { + let _ = tokio::fs::remove_file(&storage_path).await; + (s3_path, public_url) + } else { + (storage_path.clone(), format!("/assets/{}", storage_filename)) + }; + // Record in DB sqlx::query( r#" @@ -105,9 +347,9 @@ pub async fn upload_asset( .bind(org_ctx.id) .bind(claims.sub) .bind(course_id) - .bind(&filename) - .bind(&storage_path) - .bind(&mimetype) + .bind(&stored_filename) + .bind(&db_storage_path) + .bind(&stored_mimetype) .bind(size_bytes) .execute(&pool) .await @@ -115,9 +357,9 @@ pub async fn upload_asset( Ok(Json(AssetUploadResponse { id: asset_id, - filename, - url: format!("/assets/{}", storage_filename), - mimetype, + filename: stored_filename, + url: asset_url, + mimetype: stored_mimetype, size_bytes, })) } @@ -199,8 +441,656 @@ pub async fn delete_asset( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 3. Delete physical file (async) - let _ = tokio::fs::remove_file(&asset.storage_path).await; + // 3. Delete physical file or S3 object + let _ = delete_storage_path(&asset.storage_path).await; Ok(StatusCode::NO_CONTENT) } + +/// POST /api/assets/:id/ingest-rag - Ingesta un asset (PDF/audio/video/texto) en chunks para RAG +pub async fn ingest_asset_for_rag( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let asset: Asset = sqlx::query_as( + "SELECT * FROM assets WHERE id = $1 AND organization_id = $2" + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?; + + let extracted = extract_asset_text(&asset).await?; + let content = extracted.trim(); + + if content.len() < 80 { + return Err(( + StatusCode::BAD_REQUEST, + "No se encontró suficiente texto utilizable en el archivo".to_string(), + )); + } + + let chunks = chunk_text(content, 900); + if chunks.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "No se pudo generar contenido para RAG".to_string(), + )); + } + + sqlx::query( + r#" + DELETE FROM question_bank + WHERE organization_id = $1 + AND source = 'imported-material' + AND source_metadata->>'asset_id' = $2 + "#, + ) + .bind(org_ctx.id) + .bind(asset.id.to_string()) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cleanup failed: {}", e)))?; + + let source_kind = if asset.mimetype.starts_with("audio/") || asset.mimetype.starts_with("video/") { + "audio-transcription" + } else if asset.mimetype.contains("pdf") { + "pdf" + } else { + "text" + }; + + let skill = if asset.mimetype.starts_with("audio/") || asset.mimetype.starts_with("video/") { + Some("listening") + } else { + Some("reading") + }; + + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?; + let ollama_url = ai::get_ollama_url(); + let model = ai::get_embedding_model(); + + ingest_chunks_to_question_bank( + &pool, + org_ctx.id, + claims.sub, + &asset, + &source_kind, + skill, + &chunks, + &client, + &ollama_url, + &model, + ) + .await?; + + Ok(Json(AssetRagIngestResponse { + asset_id: asset.id, + source: source_kind.to_string(), + chunks_ingested: chunks.len(), + chars_ingested: content.len(), + })) +} + +/// POST /api/assets/import-zip - Importa todos los archivos de un ZIP a la biblioteca. +/// Campos multipart: +/// - file: ZIP requerido +/// - course_id: UUID opcional +/// - ingest_rag: true/false opcional (default false) +pub async fn import_assets_zip( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut zip_data = Vec::new(); + let mut course_id: Option = None; + let mut ingest_rag = false; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? + { + let name = field.name().unwrap_or_default().to_string(); + + if name == "file" { + zip_data = field + .bytes() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .to_vec(); + } else if name == "course_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = Uuid::parse_str(txt.trim()) { + course_id = Some(id); + } + } + } else if name == "ingest_rag" { + if let Ok(txt) = field.text().await { + let v = txt.trim().to_lowercase(); + ingest_rag = v == "1" || v == "true" || v == "yes"; + } + } + } + + if zip_data.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string())); + } + + let reader = std::io::Cursor::new(zip_data); + let mut archive = zip::ZipArchive::new(reader) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?; + + let mut imported_assets = 0usize; + let mut rag_ingested_assets = 0usize; + let mut rag_chunks_ingested = 0usize; + let mut failed_entries: Vec = Vec::new(); + + let rag_client = if ingest_rag { + Some( + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?, + ) + } else { + None + }; + let ollama_url = ai::get_ollama_url(); + let model = ai::get_embedding_model(); + + let len = archive.len(); + for i in 0..len { + let (entry_name, safe_filename, content): (String, String, Vec) = { + let mut file = archive + .by_index(i) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP read error: {}", e)))?; + + if !file.is_file() { + continue; + } + + let entry_name = file.name().to_string(); + if entry_name.starts_with("__MACOSX/") || entry_name.ends_with(".DS_Store") { + continue; + } + + let safe_filename = std::path::Path::new(&entry_name) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unnamed") + .to_string(); + + let mut content = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut content) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP entry read failed: {}", e)))?; + + (entry_name, safe_filename, content) + }; + + let asset_id = Uuid::new_v4(); + let guessed_mimetype = mime_guess::from_path(&safe_filename) + .first_or_octet_stream() + .to_string(); + + let (storage_path, stored_filename, mimetype) = if is_flv_media(&safe_filename, &guessed_mimetype) { + let temp_storage_filename = format!("{}.flv", asset_id); + let temp_storage_path = format!("uploads/{}", temp_storage_filename); + tokio::fs::create_dir_all("uploads") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tokio::fs::write(&temp_storage_path, &content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let final_storage_filename = format!("{}.mp4", asset_id); + let final_storage_path = format!("uploads/{}", final_storage_filename); + if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await { + let _ = tokio::fs::remove_file(&temp_storage_path).await; + failed_entries.push(format!("{}: flv transcode failed ({})", entry_name, msg)); + continue; + } + let _ = tokio::fs::remove_file(&temp_storage_path).await; + + (final_storage_path, replace_extension(&safe_filename, "mp4"), "video/mp4".to_string()) + } else { + let extension = StdPath::new(&safe_filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let storage_filename = if extension.is_empty() { + asset_id.to_string() + } else { + format!("{}.{}", asset_id, extension) + }; + let storage_path = format!("uploads/{}", storage_filename); + + tokio::fs::create_dir_all("uploads") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tokio::fs::write(&storage_path, &content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + (storage_path, safe_filename.clone(), guessed_mimetype) + }; + + let storage_filename_for_s3 = StdPath::new(&storage_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let (db_storage_path, _asset_url) = if !storage_filename_for_s3.is_empty() { + if let Some((s3_path, public_url)) = maybe_push_local_file_to_s3( + &storage_path, + &storage_filename_for_s3, + &mimetype, + org_ctx.id, + course_id, + ) + .await? + { + let _ = tokio::fs::remove_file(&storage_path).await; + (s3_path, public_url) + } else { + ( + storage_path.clone(), + format!("/assets/{}", storage_filename_for_s3), + ) + } + } else { + (storage_path.clone(), storage_path.clone()) + }; + + let persisted_size = if db_storage_path == storage_path { + tokio::fs::metadata(&storage_path) + .await + .map(|m| m.len() as i64) + .unwrap_or(content.len() as i64) + } else { + content.len() as i64 + }; + + let insert_result = sqlx::query( + r#" + INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(asset_id) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(course_id) + .bind(&stored_filename) + .bind(&db_storage_path) + .bind(&mimetype) + .bind(persisted_size) + .execute(&pool) + .await; + + if let Err(e) = insert_result { + failed_entries.push(format!("{}: db insert failed ({})", entry_name, e)); + continue; + } + + imported_assets += 1; + + if ingest_rag { + let asset = Asset { + id: asset_id, + organization_id: org_ctx.id, + uploaded_by: Some(claims.sub), + course_id, + filename: stored_filename.clone(), + storage_path: db_storage_path.clone(), + mimetype: mimetype.clone(), + size_bytes: persisted_size, + created_at: chrono::Utc::now(), + }; + + match extract_asset_text(&asset).await { + Ok(extracted) => { + let trimmed = extracted.trim(); + if trimmed.len() < 80 { + failed_entries.push(format!("{}: contenido insuficiente para RAG", entry_name)); + continue; + } + + let chunks = chunk_text(trimmed, 900); + if chunks.is_empty() { + failed_entries.push(format!("{}: no se pudieron generar chunks", entry_name)); + continue; + } + + let source_kind = if mimetype.starts_with("audio/") || mimetype.starts_with("video/") { + "audio-transcription" + } else if mimetype.contains("pdf") { + "pdf" + } else { + "text" + }; + + let skill = if mimetype.starts_with("audio/") || mimetype.starts_with("video/") { + Some("listening") + } else { + Some("reading") + }; + + if let Some(client) = &rag_client { + match ingest_chunks_to_question_bank( + &pool, + org_ctx.id, + claims.sub, + &asset, + source_kind, + skill, + &chunks, + client, + &ollama_url, + &model, + ) + .await + { + Ok(()) => { + rag_ingested_assets += 1; + rag_chunks_ingested += chunks.len(); + } + Err((_, msg)) => { + failed_entries.push(format!("{}: rag ingest failed ({})", entry_name, msg)); + } + } + } + } + Err((_, msg)) => { + failed_entries.push(format!("{}: extract failed ({})", entry_name, msg)); + } + } + } + } + + Ok(Json(AssetZipImportResponse { + imported_assets, + rag_ingested_assets, + rag_chunks_ingested, + failed_entries, + })) +} + +fn is_flv_media(filename: &str, mimetype: &str) -> bool { + let lower_name = filename.to_lowercase(); + let lower_mt = mimetype.to_lowercase(); + lower_name.ends_with(".flv") + || lower_mt == "video/x-flv" + || lower_mt == "video/flv" + || lower_mt.ends_with("/x-flv") +} + +fn replace_extension(filename: &str, new_ext: &str) -> String { + let base = StdPath::new(filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + format!("{}.{}", base, new_ext) +} + +async fn transcode_flv_to_mp4(input_path: &str, output_path: &str) -> Result<(), (StatusCode, String)> { + let output = Command::new("ffmpeg") + .arg("-y") + .arg("-i") + .arg(input_path) + .arg("-c:v") + .arg("libx264") + .arg("-c:a") + .arg("aac") + .arg("-movflags") + .arg("+faststart") + .arg(output_path) + .output() + .await + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("No se pudo convertir FLV a MP4 (ffmpeg no disponible): {}", e), + ) + })?; + + if !output.status.success() { + return Err(( + StatusCode::BAD_REQUEST, + format!( + "Error convirtiendo FLV a MP4: {}", + String::from_utf8_lossy(&output.stderr) + ), + )); + } + + Ok(()) +} + +async fn ingest_chunks_to_question_bank( + pool: &PgPool, + org_id: Uuid, + user_id: Uuid, + asset: &Asset, + source_kind: &str, + skill: Option<&str>, + chunks: &[String], + client: &reqwest::Client, + ollama_url: &str, + model: &str, +) -> Result<(), (StatusCode, String)> { + for (idx, chunk) in chunks.iter().enumerate() { + let metadata = json!({ + "asset_id": asset.id, + "asset_filename": asset.filename, + "mimetype": asset.mimetype, + "course_id": asset.course_id, + "source_kind": source_kind, + "chunk_index": idx + 1, + "chunk_total": chunks.len(), + }); + + let inserted_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO question_bank ( + organization_id, + created_by, + question_text, + question_type, + explanation, + difficulty, + skill_assessed, + source, + source_metadata, + is_active, + is_archived + ) + VALUES ($1, $2, $3, 'short-answer', $4, 'medium', $5, 'imported-material', $6, true, false) + RETURNING id + "#, + ) + .bind(org_id) + .bind(user_id) + .bind(chunk) + .bind("RAG material chunk from uploaded asset") + .bind(skill) + .bind(&metadata) + .fetch_one(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert failed: {}", e)))?; + + if let Ok(embedding_res) = generate_embedding(client, ollama_url, model, chunk).await { + let pgvector = ai::embedding_to_pgvector(&embedding_res.embedding); + let _ = sqlx::query( + r#" + UPDATE question_bank + SET embedding = $1::vector, + embedding_updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(&pgvector) + .bind(inserted_id) + .execute(pool) + .await; + } + } + + Ok(()) +} + +async fn extract_asset_text(asset: &Asset) -> Result { + let lower_name = asset.filename.to_lowercase(); + let mimetype = asset.mimetype.to_lowercase(); + + if mimetype.starts_with("audio/") || mimetype.starts_with("video/") { + let bytes = read_storage_bytes(&asset.storage_path).await?; + return transcribe_media_bytes(bytes, &asset.filename).await; + } + + if mimetype.contains("pdf") || lower_name.ends_with(".pdf") { + let bytes = read_storage_bytes(&asset.storage_path).await?; + return extract_pdf_text_from_bytes(bytes).await; + } + + if mimetype.starts_with("text/") + || lower_name.ends_with(".txt") + || lower_name.ends_with(".md") + || lower_name.ends_with(".csv") + || lower_name.ends_with(".json") + || lower_name.ends_with(".log") + { + let bytes = read_storage_bytes(&asset.storage_path).await?; + return Ok(String::from_utf8_lossy(&bytes).replace('\0', " ")); + } + + Err(( + StatusCode::BAD_REQUEST, + "Formato no soportado para ingesta RAG. Usa PDF, TXT/MD/CSV/JSON o audio/video".to_string(), + )) +} + +async fn extract_pdf_text_from_bytes(bytes: Vec) -> Result { + let temp_name = format!("uploads/tmp-pdf-{}.pdf", Uuid::new_v4()); + tokio::fs::create_dir_all("uploads") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Create temp dir failed: {}", e)))?; + tokio::fs::write(&temp_name, bytes) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write temp pdf failed: {}", e)))?; + + let output = Command::new("pdftotext") + .arg("-layout") + .arg(&temp_name) + .arg("-") + .output() + .await + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!( + "No se pudo extraer texto del PDF (pdftotext no disponible o falló): {}", + e + ), + ) + })?; + + let _ = tokio::fs::remove_file(&temp_name).await; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(( + StatusCode::BAD_REQUEST, + format!("pdftotext devolvió error: {}", err), + )); + } + + let text = String::from_utf8_lossy(&output.stdout).replace('\0', " "); + Ok(text) +} + +async fn transcribe_media_bytes(file_data: Vec, filename: &str) -> Result { + let whisper_url = std::env::var("WHISPER_URL") + .unwrap_or_else(|_| "http://localhost:8000".to_string()); + let client = reqwest::Client::new(); + + let form = reqwest::multipart::Form::new() + .part( + "file", + reqwest::multipart::Part::bytes(file_data).file_name(filename.to_string()), + ) + .text("model", "whisper-1") + .text("response_format", "json"); + + let response = client + .post(format!("{}/v1/audio/transcriptions", whisper_url)) + .multipart(form) + .send() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper request failed: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(( + StatusCode::BAD_GATEWAY, + format!("Whisper API error {}: {}", status, body), + )); + } + + let transcription: serde_json::Value = response + .json() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid Whisper response: {}", e)))?; + + let text = transcription + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if text.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "Whisper no pudo extraer texto del audio/video".to_string(), + )); + } + + Ok(text) +} + +fn chunk_text(text: &str, max_chars: usize) -> Vec { + let mut chunks: Vec = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + if current.len() + word.len() + 1 > max_chars && !current.is_empty() { + chunks.push(current.trim().to_string()); + current.clear(); + } + + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + + if !current.trim().is_empty() { + chunks.push(current.trim().to_string()); + } + + chunks +} diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs index f5faee7..478965e 100644 --- a/services/cms-service/src/handlers_test_templates.rs +++ b/services/cms-service/src/handlers_test_templates.rs @@ -13,14 +13,6 @@ use sqlx::PgPool; use std::time::Duration; use uuid::Uuid; -const COMPANY_SPECIFIC_RULES_ORG_ID: &str = "00000000-0000-0000-0000-000000000001"; - -fn uses_company_specific_template_rules(org_id: Uuid) -> bool { - Uuid::parse_str(COMPANY_SPECIFIC_RULES_ORG_ID) - .map(|id| id == org_id) - .unwrap_or(false) -} - // ==================== Query Parameters ==================== #[derive(Debug, Deserialize)] @@ -563,23 +555,6 @@ pub async fn apply_template_to_lesson( return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string())); } - // Company-specific business rules for template composition. - if uses_company_specific_template_rules(org_ctx.id) { - if matches!(template.test_type, TestType::CA) && template_questions.len() < 4 { - return Err(( - StatusCode::BAD_REQUEST, - "Las plantillas CA deben tener minimo 4 preguntas".to_string(), - )); - } - - if !matches!(template.test_type, TestType::CA) && template_questions.len() != 1 { - return Err(( - StatusCode::BAD_REQUEST, - "Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta".to_string(), - )); - } - } - // Build quiz_data JSON from template questions let questions_json: Vec = template_questions .iter() @@ -968,8 +943,16 @@ pub async fn generate_questions_with_rag( 1 - (qb.embedding <=> $1::vector) AS similarity FROM question_bank qb WHERE qb.organization_id = $2 - AND qb.source = 'imported-mysql' - AND ($3::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $3) + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND ( + $3::integer IS NULL + OR (qb.source_metadata->>'idCursos')::integer = $3 + ) + ) + ) AND qb.embedding IS NOT NULL ORDER BY qb.embedding <=> $1::vector LIMIT $4 @@ -1011,8 +994,16 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' - AND ($2::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $2) + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND ( + $2::integer IS NULL + OR (qb.source_metadata->>'idCursos')::integer = $2 + ) + ) + ) AND ( qb.question_text ILIKE $3 OR COALESCE(qb.options::text, '') ILIKE $3 @@ -1054,8 +1045,13 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' - AND (qb.source_metadata->>'idCursos')::integer = $2 + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND (qb.source_metadata->>'idCursos')::integer = $2 + ) + ) ORDER BY qb.created_at DESC "# ) @@ -1095,8 +1091,16 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' - AND ($2::integer IS NULL OR (qb.source_metadata->>'idCursos')::integer = $2) + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND ( + $2::integer IS NULL + OR (qb.source_metadata->>'idCursos')::integer = $2 + ) + ) + ) AND ( qb.question_text ILIKE $3 OR COALESCE(qb.options::text, '') ILIKE $3 @@ -1138,8 +1142,13 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' - AND (qb.source_metadata->>'idCursos')::integer = $2 + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND (qb.source_metadata->>'idCursos')::integer = $2 + ) + ) ORDER BY qb.created_at DESC "# ) @@ -1180,8 +1189,13 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' - AND (qb.source_metadata->>'idCursos')::integer = $2 + AND ( + qb.source = 'imported-material' + OR ( + qb.source = 'imported-mysql' + AND (qb.source_metadata->>'idCursos')::integer = $2 + ) + ) ORDER BY qb.created_at DESC "# ) @@ -1212,7 +1226,7 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' + AND qb.source IN ('imported-mysql', 'imported-material') ORDER BY qb.created_at DESC "# ) @@ -1249,7 +1263,7 @@ pub async fn generate_questions_with_rag( ) as nivel_curso FROM question_bank qb WHERE qb.organization_id = $1 - AND qb.source = 'imported-mysql' + AND qb.source IN ('imported-mysql', 'imported-material') ORDER BY qb.created_at DESC "# ) @@ -1266,19 +1280,22 @@ pub async fn generate_questions_with_rag( if mysql_questions.is_empty() { return Err(( StatusCode::NOT_FOUND, - "No se encontraron preguntas importadas de MySQL para la organización. Importa preguntas del banco MySQL desde Question Bank antes de generar con IA.".to_string(), + "No se encontraron materiales RAG en la organización. Importa preguntas MySQL o ingiere PDFs/audios para generar con IA.".to_string(), )); } } // Determine course_type and level from imported data - let course_type = mysql_questions - .first() + let representative = mysql_questions + .iter() + .find(|q| !q.plan_nombre.trim().is_empty()) + .or_else(|| mysql_questions.first()); + + let course_type = representative .map(|q| get_course_type_from_plan(&q.plan_nombre)) .unwrap_or(CourseType::Regular); - let level = mysql_questions - .first() + let level = representative .map(|q| get_course_level_from_mysql(q.nivel_curso, &q.plan_nombre, "")) .unwrap_or(CourseLevel::Intermediate); diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 6ac1159..4096c78 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -239,6 +239,11 @@ async fn main() { .route("/api/ai/review-text", post(handlers::review_text)) .route("/api/assets", get(handlers_assets::list_assets)) .route("/api/assets/upload", post(handlers_assets::upload_asset)) + .route("/api/assets/import-zip", post(handlers_assets::import_assets_zip)) + .route( + "/api/assets/{id}/ingest-rag", + post(handlers_assets::ingest_asset_for_rag), + ) .route("/api/assets/{id}", delete(handlers_assets::delete_asset)) .layer(DefaultBodyLimit::disable()) /* diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index a2ab45e..a3f8f94 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -26,3 +26,6 @@ base64 = "0.22" utoipa.workspace = true thiserror.workspace = true http.workspace = true +mime_guess = "2.0" +aws-config = "1" +aws-sdk-s3 = "1" diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index b3ee802..4eaa5d4 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -5,6 +5,12 @@ use axum::{ response::IntoResponse, Extension, }; +use aws_config::BehaviorVersion; +use aws_config::meta::region::RegionProviderChain; +use aws_sdk_s3::{ + Client as S3Client, + config::{Credentials, Region}, +}; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; use common::auth::{Claims, create_jwt}; @@ -88,6 +94,103 @@ use sqlx::{PgPool, Row}; use std::env; use uuid::Uuid; +#[derive(Debug, Clone)] +struct S3AudioSettings { + bucket: String, + region: String, + endpoint: Option, + public_base_url: Option, + force_path_style: bool, +} + +fn get_s3_audio_settings() -> Option { + let storage_mode = env::var("ASSETS_STORAGE") + .unwrap_or_else(|_| "local".to_string()) + .to_lowercase(); + + if storage_mode != "s3" { + return None; + } + + let bucket = env::var("S3_BUCKET").ok()?; + let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-2".to_string()); + let endpoint = env::var("S3_ENDPOINT").ok().filter(|v| !v.trim().is_empty()); + let public_base_url = env::var("S3_PUBLIC_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()); + let force_path_style = env::var("S3_FORCE_PATH_STYLE") + .map(|v| { + let lv = v.to_lowercase(); + lv == "1" || lv == "true" || lv == "yes" + }) + .unwrap_or(false); + + Some(S3AudioSettings { + bucket, + region, + endpoint, + public_base_url, + force_path_style, + }) +} + +async fn build_s3_audio_client(settings: &S3AudioSettings) -> Result { + let region_provider = + RegionProviderChain::first_try(Some(Region::new(settings.region.clone()))).or_default_provider(); + let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region_provider); + + let access_key = env::var("AWS_ACCESS_KEY_ID").ok(); + let secret_key = env::var("AWS_SECRET_ACCESS_KEY").ok(); + if let (Some(ak), Some(sk)) = (access_key, secret_key) { + let creds = Credentials::new(ak, sk, None, None, "env"); + loader = loader.credentials_provider(creds); + } + + let shared = loader.load().await; + let mut builder = aws_sdk_s3::config::Builder::from(&shared); + if let Some(endpoint) = &settings.endpoint { + builder = builder.endpoint_url(endpoint); + } + if settings.force_path_style { + builder = builder.force_path_style(true); + } + + Ok(S3Client::from_conf(builder.build())) +} + +fn build_s3_audio_key( + org_id: Uuid, + course_id: Uuid, + lesson_id: Uuid, + user_id: Uuid, + response_id: Uuid, + extension: &str, +) -> String { + let ext = if extension.is_empty() { "webm" } else { extension }; + format!( + "org/{}/course/{}/lesson/{}/audio-responses/{}/{}.{}", + org_id, course_id, lesson_id, user_id, response_id, ext + ) +} + +fn build_s3_audio_public_url(settings: &S3AudioSettings, key: &str) -> String { + if let Some(base) = &settings.public_base_url { + return format!("{}/{}", base.trim_end_matches('/'), key); + } + format!( + "https://{}.s3.{}.amazonaws.com/{}", + settings.bucket, settings.region, key + ) +} + +fn parse_s3_url(url: &str) -> Option<(String, String)> { + if let Some(without) = url.strip_prefix("s3://") { + let (bucket, key) = without.split_once('/')?; + return Some((bucket.to_string(), key.to_string())); + } + None +} + fn get_ai_url(var_base: &str, default: &str) -> String { let env = env::var("ENVIRONMENT").unwrap_or_else(|_| "prod".to_string()); if env == "dev" { @@ -2270,7 +2373,7 @@ pub async fn evaluate_audio_file( let form = reqwest::multipart::Form::new() .part( "file", - reqwest::multipart::Part::bytes(audio_data.clone()).file_name(filename), + reqwest::multipart::Part::bytes(audio_data.clone()).file_name(filename.clone()), ) .text("model", "whisper-1") .text("response_format", "json"); @@ -2401,6 +2504,7 @@ pub async fn evaluate_audio_file( // 3. Save audio response to database // Determine status based on evaluation let status = "ai_evaluated"; + let response_id = Uuid::new_v4(); // Get attempt number (check if there's a previous response for this block) let attempt_number: i32 = sqlx::query_scalar( @@ -2413,16 +2517,73 @@ pub async fn evaluate_audio_file( .await .unwrap_or(1); - // Store audio as base64 for now (can be moved to object storage later) - let audio_base64 = base64::engine::general_purpose::STANDARD.encode(&audio_data); + // Store in S3 when configured; otherwise keep legacy DB storage for compatibility. + let mut audio_url: Option = None; + let mut audio_data_db: Option> = None; + + if let Some(settings) = get_s3_audio_settings() { + let extension = std::path::Path::new(&filename) + .extension() + .and_then(|v| v.to_str()) + .unwrap_or("webm"); + let content_type = mime_guess::from_path(&filename) + .first_or_octet_stream() + .to_string(); + let key = build_s3_audio_key( + org_ctx.id, + course_id, + lesson_id, + claims.sub, + response_id, + extension, + ); + + match build_s3_audio_client(&settings).await { + Ok(s3_client) => { + let put_result = s3_client + .put_object() + .bucket(&settings.bucket) + .key(&key) + .content_type(content_type) + .body(audio_data.clone().into()) + .send() + .await; + + if put_result.is_ok() { + audio_url = Some(build_s3_audio_public_url(&settings, &key)); + } else { + // Fallback to DB storage if S3 upload fails. + audio_data_db = Some( + base64::engine::general_purpose::STANDARD + .encode(&audio_data) + .into_bytes(), + ); + } + } + Err(_) => { + audio_data_db = Some( + base64::engine::general_purpose::STANDARD + .encode(&audio_data) + .into_bytes(), + ); + } + } + } else { + audio_data_db = Some( + base64::engine::general_purpose::STANDARD + .encode(&audio_data) + .into_bytes(), + ); + } let _ = sqlx::query( r#"INSERT INTO audio_responses - (organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_data, + (id, organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_url, audio_data, ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at, status, attempt_number, duration_seconds) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), $12, $13, $14)"# + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15, $16)"# ) + .bind(response_id) .bind(org_ctx.id) .bind(claims.sub) .bind(course_id) @@ -2430,7 +2591,8 @@ pub async fn evaluate_audio_file( .bind(block_id) .bind(&prompt) .bind(&transcript) - .bind(&audio_base64) + .bind(&audio_url) + .bind(&audio_data_db) .bind(grading.score) .bind(&grading.found_keywords) .bind(&grading.feedback) @@ -2476,6 +2638,37 @@ pub struct AudioResponseFilters { pub user_id: Option, } +async fn instructor_has_course_access( + pool: &PgPool, + org_id: Uuid, + instructor_id: Uuid, + course_id: Uuid, +) -> Result { + let has_access: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 + FROM course_instructors ci + JOIN courses c ON c.id = ci.course_id + WHERE c.organization_id = $1 + AND ci.course_id = $2 + AND ci.user_id = $3 + ) + "#, + ) + .bind(org_id) + .bind(course_id) + .bind(instructor_id) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Error validating instructor course access: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(has_access) +} + /// Get all audio responses for teachers /// Filters: course_id, lesson_id, status (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id pub async fn get_audio_responses( @@ -2489,7 +2682,9 @@ pub async fn get_audio_responses( return Err(StatusCode::FORBIDDEN); } - // Use static query with optional filters + let is_instructor = claims.role == "instructor"; + + // Use static query with optional filters + instructor scoping let responses = sqlx::query_as::<_, AudioResponseListItem>( r#" SELECT @@ -2517,14 +2712,30 @@ pub async fn get_audio_responses( JOIN courses c ON ar.course_id = c.id JOIN lessons l ON ar.lesson_id = l.id WHERE ar.organization_id = $1 - AND ($2::uuid IS NULL OR ar.course_id = $2) - AND ($3::uuid IS NULL OR ar.lesson_id = $3) - AND ($4::text IS NULL OR ar.status::text = $4) - AND ($5::uuid IS NULL OR ar.user_id = $5) + AND ( + $2::boolean = false + OR EXISTS ( + SELECT 1 + FROM course_instructors ci + WHERE ci.organization_id = ar.organization_id + AND ci.course_id = ar.course_id + AND ci.user_id = $3 + ) + ) + AND ($4::uuid IS NULL OR ar.course_id = $4) + AND ($5::uuid IS NULL OR ar.lesson_id = $5) + AND ( + $6::text IS NULL + OR ($6::text = 'pending_instructor' AND ar.status::text IN ('pending', 'ai_evaluated')) + OR ($6::text != 'pending_instructor' AND ar.status::text = $6::text) + ) + AND ($7::uuid IS NULL OR ar.user_id = $7) ORDER BY ar.created_at DESC "# ) .bind(org_ctx.id) + .bind(is_instructor) + .bind(claims.sub) .bind(filters.course_id) .bind(filters.lesson_id) .bind(filters.status) @@ -2590,7 +2801,14 @@ pub async fn get_audio_response_detail( })?; match response { - Some(r) => Ok(Json(r)), + Some(r) => { + if claims.role == "instructor" + && !instructor_has_course_access(&pool, org_ctx.id, claims.sub, r.course_id).await? + { + return Err(StatusCode::FORBIDDEN); + } + Ok(Json(r)) + } None => Err(StatusCode::NOT_FOUND), } } @@ -2598,13 +2816,17 @@ pub async fn get_audio_response_detail( /// Get audio data as base64 for playback pub async fn get_audio_response_audio( Org(org_ctx): Org, - _claims: Claims, + claims: Claims, State(pool): State, Path(response_id): Path, ) -> Result { + if claims.role != "admin" && claims.role != "instructor" && claims.role != "student" { + return Err(StatusCode::FORBIDDEN); + } + // Only instructors, admins, and the owner can access - let audio_data: Option> = sqlx::query_scalar( - "SELECT audio_data FROM audio_responses WHERE id = $1 AND organization_id = $2" + let row: Option<(Option>, Option, Uuid, Uuid)> = sqlx::query_as( + "SELECT audio_data, audio_url, user_id, course_id FROM audio_responses WHERE id = $1 AND organization_id = $2" ) .bind(response_id) .bind(org_ctx.id) @@ -2615,23 +2837,104 @@ pub async fn get_audio_response_audio( StatusCode::INTERNAL_SERVER_ERROR })?; - match audio_data { - Some(data) => { - // Decode from base64 - let audio_bytes = base64::engine::general_purpose::STANDARD.decode(&data) + match row { + Some((audio_data, audio_url, owner_user_id, course_id)) => { + // Access rules: admin always, instructor only their courses, student only own response. + if claims.role == "student" && claims.sub != owner_user_id { + return Err(StatusCode::FORBIDDEN); + } + if claims.role == "instructor" + && !instructor_has_course_access(&pool, org_ctx.id, claims.sub, course_id).await? + { + return Err(StatusCode::FORBIDDEN); + } + + if let Some(data) = audio_data { + // Legacy path: DB contains base64 bytes. + let audio_bytes = base64::engine::general_purpose::STANDARD + .decode(&data) + .unwrap_or(data); + + Ok( + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "audio/webm") + .header(axum::http::header::CONTENT_DISPOSITION, "inline") + .body(axum::body::Body::from(audio_bytes)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_response(), + ) + } else if let Some(audio_url) = audio_url { + let (audio_bytes, content_type) = read_audio_response_from_url(&audio_url) + .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(axum::response::Response::builder() - .header(axum::http::header::CONTENT_TYPE, "audio/webm") - .header(axum::http::header::CONTENT_DISPOSITION, "inline") - .body(axum::body::Body::from(audio_bytes)) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .into_response()) + + Ok( + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, content_type) + .header(axum::http::header::CONTENT_DISPOSITION, "inline") + .body(axum::body::Body::from(audio_bytes)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_response(), + ) + } else { + Err(StatusCode::NOT_FOUND) + } } None => Err(StatusCode::NOT_FOUND), } } +async fn read_audio_response_from_url(url: &str) -> Result<(Vec, String), String> { + if let Some((bucket, key)) = parse_s3_url(url) { + let settings = get_s3_audio_settings() + .ok_or_else(|| "S3 audio settings are missing".to_string())?; + let client = build_s3_audio_client(&settings).await?; + let output = client + .get_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(|e| format!("S3 read failed: {}", e))?; + let content_type = output + .content_type() + .map(|s| s.to_string()) + .unwrap_or_else(|| "audio/webm".to_string()); + let bytes = output + .body + .collect() + .await + .map_err(|e| format!("S3 body read failed: {}", e))? + .into_bytes() + .to_vec(); + return Ok((bytes, content_type)); + } + + let response = reqwest::Client::new() + .get(url) + .send() + .await + .map_err(|e| format!("HTTP audio fetch failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("HTTP audio fetch status: {}", response.status())); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("audio/webm") + .to_string(); + let bytes = response + .bytes() + .await + .map_err(|e| format!("HTTP audio bytes failed: {}", e))? + .to_vec(); + + Ok((bytes, content_type)) +} + /// Teacher evaluates an audio response pub async fn teacher_evaluate_audio( Org(org_ctx): Org, @@ -2651,8 +2954,8 @@ pub async fn teacher_evaluate_audio( } // Get current response to determine new status - let current_status: String = sqlx::query_scalar( - "SELECT status::text FROM audio_responses WHERE id = $1 AND organization_id = $2" + let response_meta: Option<(String, Uuid)> = sqlx::query_as( + "SELECT status::text, course_id FROM audio_responses WHERE id = $1 AND organization_id = $2" ) .bind(response_id) .bind(org_ctx.id) @@ -2662,7 +2965,15 @@ pub async fn teacher_evaluate_audio( tracing::error!("Error fetching audio response: {}", e); StatusCode::INTERNAL_SERVER_ERROR })? - .unwrap_or_else(|| "pending".to_string()); + ; + + let (current_status, course_id) = response_meta.ok_or(StatusCode::NOT_FOUND)?; + + if claims.role == "instructor" + && !instructor_has_course_access(&pool, org_ctx.id, claims.sub, course_id).await? + { + return Err(StatusCode::FORBIDDEN); + } // Determine new status let new_status = if current_status == "ai_evaluated" { @@ -2720,6 +3031,12 @@ pub async fn get_audio_response_stats( return Err(StatusCode::FORBIDDEN); } + if claims.role == "instructor" + && !instructor_has_course_access(&pool, org_ctx.id, claims.sub, course_id).await? + { + return Err(StatusCode::FORBIDDEN); + } + let stats = sqlx::query_as::<_, common::models::AudioResponseStats>( r#" SELECT diff --git a/web/studio/src/app/admin/audio-evaluations/page.tsx b/web/studio/src/app/admin/audio-evaluations/page.tsx index e09ffc2..73ffca9 100644 --- a/web/studio/src/app/admin/audio-evaluations/page.tsx +++ b/web/studio/src/app/admin/audio-evaluations/page.tsx @@ -16,18 +16,21 @@ import { Download, RefreshCcw } from "lucide-react"; -import { lmsApi, type AudioResponse, type AudioResponseFilters } from "@/lib/api"; +import { cmsApi, lmsApi, type AudioResponse, type AudioResponseFilters, type Course } from "@/lib/api"; import PageLayout from "@/components/PageLayout"; import AuthGuard from "@/components/AuthGuard"; +import { useAuth } from "@/context/AuthContext"; export default function AudioEvaluationsPage() { const router = useRouter(); + const { user } = useAuth(); const [loading, setLoading] = useState(true); const [evaluations, setEvaluations] = useState([]); + const [courses, setCourses] = useState([]); const [selectedEvaluation, setSelectedEvaluation] = useState(null); const [teacherScore, setTeacherScore] = useState(50); const [teacherFeedback, setTeacherFeedback] = useState(""); - const [filters, setFilters] = useState({}); + const [filters, setFilters] = useState({ status: 'pending_instructor' }); const [playingId, setPlayingId] = useState(null); const [audioUrl, setAudioUrl] = useState(null); @@ -43,10 +46,23 @@ export default function AudioEvaluationsPage() { } }; + const fetchCourses = async () => { + try { + const data = await cmsApi.getCourses(); + setCourses(data); + } catch (error) { + console.error("Error fetching courses for audio evaluations:", error); + } + }; + useEffect(() => { fetchEvaluations(); }, [filters]); + useEffect(() => { + fetchCourses(); + }, []); + const handlePlayAudio = async (id: string) => { if (playingId === id) { // Stop playing @@ -138,6 +154,7 @@ export default function AudioEvaluationsPage() { className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20" > + @@ -145,14 +162,17 @@ export default function AudioEvaluationsPage() {
- - Curso +
@@ -167,7 +187,7 @@ export default function AudioEvaluationsPage() {