From 2607c30099260851296fb5fe0499f4c9cf3518b2 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Fri, 6 Nov 2026 13:47:44 +0200 Subject: [PATCH 1/5] feat(eDd): add s3 object store + test page --- .github/workflows/ci.yml | 4 +- Cargo.lock | 472 +++++++++++++++++- Cargo.toml | 7 + README.md | 1 - apps/examiner-dashboard/.env.example | 13 + apps/examiner-dashboard/Cargo.toml | 7 +- .../client/src/components/page-shell.tsx | 3 +- .../client/src/pages/file-dragger.astro | 8 + .../client/src/utils/fetch.ts | 47 ++ .../client/src/views/file-dragger.test.tsx | 129 +++++ .../client/src/views/file-dragger.tsx | 208 ++++++++ apps/examiner-dashboard/compose.yaml | 8 + apps/examiner-dashboard/server/app.rs | 8 + apps/examiner-dashboard/server/config.rs | 77 ++- apps/examiner-dashboard/server/error.rs | 9 + apps/examiner-dashboard/server/extractor.rs | 8 +- .../examiner-dashboard/server/routes/files.rs | 360 +++++++++++++ apps/examiner-dashboard/server/routes/mod.rs | 1 + ...6189bb324d2e48e8b710598969f7928d9838b.json | 116 +++++ ...4a2b3188b676b0244b4ec2708d574644ae899.json | 12 + .../migrations/0002_question_assets.sql | 32 ++ databases/curriculum/src/edd/assets.rs | 177 +++++++ databases/curriculum/src/edd/mod.rs | 3 + databases/curriculum/src/lib.rs | 64 ++- deploy/exam-platform.toml | 6 + docs/apps/curriculum-database.md | 6 + docs/apps/examiner-dashboard.md | 13 + docs/deployment/environment-variables.md | 41 +- libs/object-storage/Cargo.toml | 21 + libs/object-storage/src/lib.rs | 266 ++++++++++ 30 files changed, 2076 insertions(+), 51 deletions(-) create mode 100644 apps/examiner-dashboard/client/src/pages/file-dragger.astro create mode 100644 apps/examiner-dashboard/client/src/views/file-dragger.test.tsx create mode 100644 apps/examiner-dashboard/client/src/views/file-dragger.tsx create mode 100644 apps/examiner-dashboard/server/routes/files.rs create mode 100644 databases/curriculum/.sqlx/query-5d21607ba073eb9b04556fc282d6189bb324d2e48e8b710598969f7928d9838b.json create mode 100644 databases/curriculum/.sqlx/query-ab3810ab25540b4ff429041001c4a2b3188b676b0244b4ec2708d574644ae899.json create mode 100644 databases/curriculum/migrations/0002_question_assets.sql create mode 100644 databases/curriculum/src/edd/assets.rs create mode 100644 libs/object-storage/Cargo.toml create mode 100644 libs/object-storage/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b17f9c3..cc2147a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,11 +145,13 @@ jobs: - run: cargo run -p edb --bin edb-migrate --locked env: EDB_MIGRATOR_URL: postgres://edb_migrator:edb_migrator@127.0.0.1:5432/examiner - - run: cargo install sqlx-cli --version 0.9.0 --locked --no-default-features --features rustls,postgres,sqlx-toml + - run: cargo install sqlx-cli --version 0.9.0 --locked --no-default-features --features rustls,postgres,mysql,sqlx-toml - run: cargo sqlx prepare --check working-directory: databases/examiner env: EDB_MIGRATOR_URL: postgres://edb_migrator:edb_migrator@127.0.0.1:5432/examiner + - run: cargo sqlx prepare --check + working-directory: databases/curriculum desktop: name: Desktop Rust diff --git a/Cargo.lock b/Cargo.lock index 6cdf210..343794d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,15 @@ dependencies = [ "libc", ] +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + [[package]] name = "atoi" version = "2.0.0" @@ -254,6 +263,18 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-credential-types" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.18.1" @@ -277,6 +298,310 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-runtime" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.5.0", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.157.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea9794c741520d8baec9462bcd0ad12164d4f5f78d008a8da9d6a0b7222caa8" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 1.5.0", + "http-body", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.1", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 1.5.0", + "percent-encoding", + "sha2 0.11.1", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.5.0", + "http-body", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.1", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.61.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.5.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 1.5.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 1.5.0", + "http-body", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -296,6 +621,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -379,6 +705,16 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[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 = "bitflags" version = "2.13.2" @@ -433,6 +769,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[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 = "bytestring" version = "1.5.1" @@ -623,6 +969,25 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -846,12 +1211,15 @@ version = "0.5.0" dependencies = [ "axum", "axum-extra", + "cdb", "edb", "http 1.5.0", "oauth2", + "object-storage", "reqwest", "runtime", "serde", + "sha2 0.11.1", "sqlx", "thiserror 2.0.21", "time", @@ -862,6 +1230,12 @@ dependencies = [ "url", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -888,7 +1262,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.9", ] [[package]] @@ -1042,6 +1416,25 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1058,6 +1451,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" @@ -1214,6 +1610,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.5.0", "http-body", "httparse", @@ -1235,6 +1632,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1554,6 +1952,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -1628,6 +2035,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.5.0", + "httparse", + "memchr", + "mime", + "spin 0.9.9", + "version_check", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -1661,6 +2085,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1857,6 +2290,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "object-storage" +version = "0.1.0" +dependencies = [ + "aws-sdk-s3", + "bytes", + "reqwest", + "thiserror 2.0.21", + "tokio", + "url", + "uuid", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1891,6 +2337,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2748,6 +3200,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "sqlx" version = "0.9.0" @@ -3493,6 +3951,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 = "walkdir" version = "2.5.0" @@ -3805,6 +4269,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[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.3" diff --git a/Cargo.toml b/Cargo.toml index 1bbf167..a6de1aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,12 @@ publish = false [workspace.dependencies] axum = "0.8" +# S3-compatible object storage (libs/object-storage). Default HTTPS client: rustls on aws-lc-rs. +aws-sdk-s3 = { version = "1.157.0", default-features = false, features = [ + "default-https-client", + "http-1x", + "rt-tokio", +] } axum-extra = { version = "0.12", features = ["cookie-private", "typed-header"] } http = "1" tokio = { version = "1.53", features = ["full"] } @@ -53,6 +59,7 @@ sqlx = { version = "0.9.0", default-features = false, features = [ uuid = "1.28.0" cdb = { path = "databases/curriculum" } edb = { path = "databases/examiner" } +object-storage = { path = "libs/object-storage" } runtime = { path = "libs/runtime" } [workspace.lints.clippy] diff --git a/README.md b/README.md index 046549e..4b2d87f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ apps/ databases/ | curriculum/ | examiner/ - | moderation/ docs/ # mdbook docs libs/ # Common across one or more applications | runtime/ # env config, tracing + Sentry, Axum serving + graceful shutdown, exit codes diff --git a/apps/examiner-dashboard/.env.example b/apps/examiner-dashboard/.env.example index 239babb..cec5c83 100644 --- a/apps/examiner-dashboard/.env.example +++ b/apps/examiner-dashboard/.env.example @@ -19,3 +19,16 @@ COOKIE_KEY= # Hosted Postgres: append `?sslmode=verify-full`. EDB_DATABASE_URL=postgres://edd:@127.0.0.1:15432/examiner + +# cDb as eDd. Names the database, never a branch. Compose host: curriculum-db:3306. +CDB_DATABASE_URL=mysql://root:@127.0.0.1:13306/curriculum + +# Curriculum bucket (question assets): any S3-compatible provider. DO Spaces: endpoint +# https://.digitaloceanspaces.com, region us-east-1, an access key scoped to the bucket. +CURRICULUM_S3_ENDPOINT=https://nyc3.digitaloceanspaces.com +CURRICULUM_S3_REGION=us-east-1 +CURRICULUM_S3_BUCKET= +CURRICULUM_S3_ACCESS_KEY_ID= +CURRICULUM_S3_SECRET_ACCESS_KEY= +# true for path-style providers (Garage, SeaweedFS). Default false. +CURRICULUM_S3_FORCE_PATH_STYLE= diff --git a/apps/examiner-dashboard/Cargo.toml b/apps/examiner-dashboard/Cargo.toml index b66ea95..813de49 100644 --- a/apps/examiner-dashboard/Cargo.toml +++ b/apps/examiner-dashboard/Cargo.toml @@ -10,10 +10,12 @@ path = "server/main.rs" name = "server" [dependencies] -axum.workspace = true +axum = { workspace = true, features = ["multipart"] } axum-extra.workspace = true +cdb.workspace = true edb.workspace = true http.workspace = true +object-storage.workspace = true tokio.workspace = true tower-http = { workspace = true, features = ["fs"] } tracing.workspace = true @@ -22,8 +24,9 @@ runtime.workspace = true oauth2.workspace = true reqwest = { workspace = true, features = ["json", "rustls"] } serde.workspace = true +sha2.workspace = true sqlx.workspace = true -time.workspace = true +time = { workspace = true, features = ["formatting"] } url.workspace = true [dev-dependencies] diff --git a/apps/examiner-dashboard/client/src/components/page-shell.tsx b/apps/examiner-dashboard/client/src/components/page-shell.tsx index 36bda7f..b2d6a75 100644 --- a/apps/examiner-dashboard/client/src/components/page-shell.tsx +++ b/apps/examiner-dashboard/client/src/components/page-shell.tsx @@ -1,11 +1,12 @@ import { Box, Button, Center, Flex, Heading, HStack, Text } from "@chakra-ui/react"; import type { ReactNode } from "react"; -import { LuBlocks, LuGraduationCap, LuHouse } from "react-icons/lu"; +import { LuBlocks, LuFile, LuGraduationCap, LuHouse } from "react-icons/lu"; import { ColorModeButton } from "@/components/ui/color-mode"; const navItems = [ { href: "/", label: "Home", icon: LuHouse }, { href: "/question-builder", label: "Question Builder", icon: LuBlocks }, + { href: "/file-dragger", label: "File Dragger", icon: LuFile }, ]; export function PageShell({ diff --git a/apps/examiner-dashboard/client/src/pages/file-dragger.astro b/apps/examiner-dashboard/client/src/pages/file-dragger.astro new file mode 100644 index 0000000..0bf68d5 --- /dev/null +++ b/apps/examiner-dashboard/client/src/pages/file-dragger.astro @@ -0,0 +1,8 @@ +--- +import Base from "@/layouts/base.astro"; +import { FileDragger } from "@/views/file-dragger"; +--- + + + + diff --git a/apps/examiner-dashboard/client/src/utils/fetch.ts b/apps/examiner-dashboard/client/src/utils/fetch.ts index a3ac977..393fcf4 100644 --- a/apps/examiner-dashboard/client/src/utils/fetch.ts +++ b/apps/examiner-dashboard/client/src/utils/fetch.ts @@ -66,3 +66,50 @@ export async function postDevLogin(user: { name: string; email: string }): Promi throw await responseError(res); } } + +// Mirrors the server's limits (server/routes/files.rs), so the dropzone rejects what it would. +export const MAX_FILE_BYTES = 10 * 1024 * 1024; +export const MAX_FILES_PER_UPLOAD = 5; +// The server sniffs the bytes and rejects anything else: this only filters the file picker. +export const ACCEPTED_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/flac", + "audio/mp4", +]; + +export interface StoredFile { + // What a question's `question_assets` row stores. + sha256: string; + // Object key, `sha256/.`. + key: string; + // Sniffed by the server from the bytes. + contentType: string; + size: number; + // As first uploaded. + fileName: string; + // RFC 3339. `null` in an upload's response. + createdAt: string | null; + // Presigned, expires in minutes. Display only: never store it. + url: string; +} + +export async function getFiles(): Promise { + const res = await authorizedFetch("/api/files"); + return (await res.json()) as StoredFile[]; +} + +export async function uploadFiles(files: File[]): Promise { + const body = new FormData(); + for (const file of files) { + body.append("file", file); + } + + const res = await authorizedFetch("/api/files", { method: "POST", body }); + return (await res.json()) as StoredFile[]; +} diff --git a/apps/examiner-dashboard/client/src/views/file-dragger.test.tsx b/apps/examiner-dashboard/client/src/views/file-dragger.test.tsx new file mode 100644 index 0000000..5dcfafa --- /dev/null +++ b/apps/examiner-dashboard/client/src/views/file-dragger.test.tsx @@ -0,0 +1,129 @@ +/* @vitest-environment jsdom */ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import type { StoredFile } from "@/utils/fetch"; +import { camperbot } from "../../test-utils/session"; +import { FileDragger, formatBytes } from "./file-dragger.tsx"; + +vi.mock("@/utils/navigate"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +const diagramHash = "a".repeat(64); +const diagram: StoredFile = { + sha256: diagramHash, + key: `sha256/${diagramHash}.png`, + contentType: "image/png", + size: 2048, + fileName: "diagram.png", + createdAt: "2026-11-05T10:00:00Z", + url: "https://bucket.example/diagram.png?X-Amz-Signature=abc", +}; + +const listeningHash = "b".repeat(64); +const listening: StoredFile = { + ...diagram, + sha256: listeningHash, + key: `sha256/${listeningHash}.mp3`, + contentType: "audio/mpeg", + fileName: "listening-1.mp3", + url: "https://bucket.example/listening.mp3?X-Amz-Signature=abc", +}; + +// A logged-in session, and in-memory assets behind /api/files. +function mockServer(initial: StoredFile[]) { + const stored = [...initial]; + const fetch = vi.fn((url: string, init?: RequestInit) => { + if (url === "/auth/session") { + return Promise.resolve(Response.json(camperbot)); + } + if (url === "/api/files" && init?.method === "POST") { + const body = init.body as FormData; + const uploaded: StoredFile[] = body.getAll("file").map((file, i) => ({ + sha256: String(i).repeat(64), + key: `sha256/${String(i).repeat(64)}.png`, + contentType: "image/png", + size: (file as File).size, + fileName: (file as File).name, + createdAt: null, + url: "https://bucket.example/new", + })); + stored.unshift(...uploaded); + return Promise.resolve(Response.json(uploaded, { status: 201 })); + } + if (url === "/api/files") { + return Promise.resolve(Response.json(stored)); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }); + vi.stubGlobal("fetch", fetch); + return fetch; +} + +test("lists uploaded files by name and hash, with image previews", async () => { + mockServer([diagram]); + + const screen = render(); + + expect(await screen.findByText(diagram.sha256)).toBeInTheDocument(); + expect(screen.getByText(diagram.fileName)).toBeInTheDocument(); + expect(screen.getByRole("img", { name: diagram.fileName })).toHaveAttribute("src", diagram.url); + expect(screen.getByText(/2\.0 KiB/)).toBeInTheDocument(); +}); + +test("previews audio with a player", async () => { + mockServer([listening]); + + const screen = render(); + + await screen.findByText(listening.fileName); + const audio = screen.container.querySelector("audio"); + expect(audio).toHaveAttribute("src", listening.url); + expect(audio).toHaveAttribute("controls"); +}); + +test("says when there are no files", async () => { + mockServer([]); + + const screen = render(); + + expect(await screen.findByText("No files yet.")).toBeInTheDocument(); +}); + +test("uploads chosen files, then lists them", async () => { + const fetch = mockServer([]); + const screen = render(); + await screen.findByText("No files yet."); + const image = new File(["png bytes"], "diagram.png", { type: "image/png" }); + + // Hidden from the accessibility tree: the dropzone is the accessible control. + const input = screen.container.querySelector("input[type=file]"); + fireEvent.input(input!, { target: { files: [image] } }); + fireEvent.click(await screen.findByRole("button", { name: /Upload 1 file/ })); + + expect(await screen.findByText("0".repeat(64))).toBeInTheDocument(); + const post = fetch.mock.calls.find(([, init]) => init?.method === "POST"); + const body = post?.[1]?.body; + expect(body).toBeInstanceOf(FormData); + expect((body as FormData).getAll("file")).toEqual([image]); + expect(screen.getByRole("button", { name: /Upload 0 files/ })).toBeDisabled(); +}); + +test("upload button is disabled until files are chosen", async () => { + mockServer([]); + + const screen = render(); + + expect(await screen.findByRole("button", { name: /Upload 0 files/ })).toBeDisabled(); +}); + +test("formats sizes", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KiB"); + expect(formatBytes(10 * 1024 * 1024)).toBe("10.0 MiB"); +}); diff --git a/apps/examiner-dashboard/client/src/views/file-dragger.tsx b/apps/examiner-dashboard/client/src/views/file-dragger.tsx new file mode 100644 index 0000000..8961b03 --- /dev/null +++ b/apps/examiner-dashboard/client/src/views/file-dragger.tsx @@ -0,0 +1,208 @@ +import { + Alert, + Box, + Button, + Card, + Center, + Clipboard, + FileUpload, + Heading, + HStack, + IconButton, + Image, + SimpleGrid, + Spinner, + Text, + useFileUpload, + VStack, +} from "@chakra-ui/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LuFile, LuUpload } from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { + ACCEPTED_TYPES, + getFiles, + MAX_FILE_BYTES, + MAX_FILES_PER_UPLOAD, + type StoredFile, + uploadFiles, +} from "@/utils/fetch"; + +const FILES_QUERY_KEY = ["files"]; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function Uploader() { + const queryClient = useQueryClient(); + const fileUpload = useFileUpload({ + maxFiles: MAX_FILES_PER_UPLOAD, + maxFileSize: MAX_FILE_BYTES, + accept: ACCEPTED_TYPES, + }); + const upload = useMutation({ + mutationFn: uploadFiles, + onSuccess: async () => { + fileUpload.clearFiles(); + await queryClient.invalidateQueries({ queryKey: FILES_QUERY_KEY }); + }, + }); + + const pending = fileUpload.acceptedFiles; + + return ( + + + + + + + + + Drag files here, or click to choose + + PNG, JPEG, GIF, WebP, MP3, WAV, Ogg, FLAC or M4A. Up to {MAX_FILES_PER_UPLOAD} files,{" "} + {formatBytes(MAX_FILE_BYTES)} each. + + + + + + + {fileUpload.rejectedFiles.map(({ file, errors }) => ( + + {file.name}: {errors.join(", ").toLowerCase().replaceAll("_", " ")} + + ))} + + {upload.error && ( + + + Upload failed: {upload.error.message} + + )} + + + + + + + ); +} + +function Preview({ file }: { file: StoredFile }) { + if (file.contentType.startsWith("image/")) { + return {file.fileName}; + } + if (file.contentType.startsWith("audio/")) { + return