diff --git a/Cargo.lock b/Cargo.lock index 6cdf210..4b5c924 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" @@ -776,6 +1141,7 @@ version = "0.1.0" dependencies = [ "sha2 0.11.1", "sqlx", + "thiserror 2.0.21", "time", "tokio", "uuid", @@ -849,6 +1215,7 @@ dependencies = [ "edb", "http 1.5.0", "oauth2", + "object-storage", "reqwest", "runtime", "serde", @@ -862,6 +1229,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 +1261,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.9", ] [[package]] @@ -1042,6 +1415,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 +1450,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 +1609,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.5.0", "http-body", "httparse", @@ -1235,6 +1631,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1554,6 +1951,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 +2034,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 +2084,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 +2289,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "object-storage" +version = "0.1.0" +dependencies = [ + "aws-sdk-s3", + "bytes", + "reqwest", + "runtime", + "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..df5a075 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"] } @@ -38,6 +44,7 @@ oauth2 = { version = "5.0.0", default-features = false } serde = { version = "1.0.229", features = ["derive"] } time = "0.3.55" url = "2.5.8" +bytes = "1.12.1" sha2 = "0.11.1" # aws-lc-rs: the rustls provider reqwest already uses. sqlx = { version = "0.9.0", default-features = false, features = [ @@ -53,6 +60,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..3be8549 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ apps/ databases/ | curriculum/ | examiner/ - | moderation/ docs/ # mdbook docs libs/ # Common across one or more applications + | object-storage/ # S3-compatible client, one bucket per store, configured from env | 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..8b52b60 100644 --- a/apps/examiner-dashboard/.env.example +++ b/apps/examiner-dashboard/.env.example @@ -19,3 +19,13 @@ COOKIE_KEY= # Hosted Postgres: append `?sslmode=verify-full`. EDB_DATABASE_URL=postgres://edd:@127.0.0.1:15432/examiner + +# Curriculum bucket (question objects): 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/.oxfmtrc.json b/apps/examiner-dashboard/.oxfmtrc.json index 6ab06d6..46230dc 100644 --- a/apps/examiner-dashboard/.oxfmtrc.json +++ b/apps/examiner-dashboard/.oxfmtrc.json @@ -1,5 +1,5 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["server/**", "dist/**", ".astro/**"], + "ignorePatterns": ["server/**", "dist/**", ".astro/**", "CHANGELOG.md"], "astro": true } diff --git a/apps/examiner-dashboard/Cargo.toml b/apps/examiner-dashboard/Cargo.toml index b66ea95..1b27a63 100644 --- a/apps/examiner-dashboard/Cargo.toml +++ b/apps/examiner-dashboard/Cargo.toml @@ -10,10 +10,11 @@ path = "server/main.rs" name = "server" [dependencies] -axum.workspace = true +axum = { workspace = true, features = ["multipart"] } axum-extra.workspace = true edb.workspace = true http.workspace = true +object-storage.workspace = true tokio.workspace = true tower-http = { workspace = true, features = ["fs"] } tracing.workspace = true @@ -23,7 +24,7 @@ oauth2.workspace = true reqwest = { workspace = true, features = ["json", "rustls"] } serde.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..449e766 100644 --- a/apps/examiner-dashboard/client/src/components/page-shell.tsx +++ b/apps/examiner-dashboard/client/src/components/page-shell.tsx @@ -1,11 +1,13 @@ 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, LuDatabase, 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 }, + { href: "/object-store", label: "Object Store", icon: LuDatabase }, ]; 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/pages/object-store.astro b/apps/examiner-dashboard/client/src/pages/object-store.astro new file mode 100644 index 0000000..038a03d --- /dev/null +++ b/apps/examiner-dashboard/client/src/pages/object-store.astro @@ -0,0 +1,8 @@ +--- +import Base from "@/layouts/base.astro"; +import { ObjectStore } from "@/views/object-store"; +--- + + + + diff --git a/apps/examiner-dashboard/client/src/utils/fetch.ts b/apps/examiner-dashboard/client/src/utils/fetch.ts index a3ac977..250eb7b 100644 --- a/apps/examiner-dashboard/client/src/utils/fetch.ts +++ b/apps/examiner-dashboard/client/src/utils/fetch.ts @@ -25,8 +25,7 @@ async function responseError(res: Response): Promise { return new HttpError(res.status, `${res.status} - ${body || res.statusText}`); } -// For every request needing login. 401 (no or expired session) sends the user to /login; other -// failures, 403 included, reject with the server's message. +// For every request needing login. 401 (no or expired session) sends the user to /login; other failures, 403 included, reject with the server's message. export async function authorizedFetch(url: string, options?: RequestInit): Promise { const res = await fetch(url, options); @@ -66,3 +65,83 @@ export async function postDevLogin(user: { name: string; email: string }): Promi throw await responseError(res); } } + +// An object's eDb record. +export interface ObjectRecord { + // What a question's `question_objects` row stores. + sha256: string; + // Sniffed by the server from the bytes. + contentType: string; + // As first uploaded, unless renamed. + fileName: string; + // RFC 3339. + createdAt: string; +} + +// A recorded object: its record, with its bytes' key and a link to them. +export interface RecordedObject extends ObjectRecord { + // In the curriculum bucket: `sha256/.`. + objectKey: string; + byteSize: number; + // Presigned, expires in minutes. Display only: never store it. + url: string; +} + +export async function getObjects(): Promise { + const res = await authorizedFetch("/api/objects"); + return (await res.json()) as RecordedObject[]; +} + +// Already-stored files come back as stored, under their first upload's name. +export async function uploadObjects(files: File[]): Promise { + const body = new FormData(); + for (const file of files) { + body.append("file", file); + } + + const res = await authorizedFetch("/api/objects", { method: "POST", body }); + return (await res.json()) as RecordedObject[]; +} + +// `recorded` - in the bucket and eDb. +// `unrecorded` - in the bucket only. +// `missing` - in eDb only, its bytes gone. +export type StoreEntryStatus = "recorded" | "unrecorded" | "missing"; + +// One key in the curriculum bucket, eDb or both. +export interface StoreEntry { + key: string; + status: StoreEntryStatus; + // Null when unrecorded. + record: ObjectRecord | null; + // The bucket's when listed, else eDb's. + byteSize: number; + // RFC 3339. Null when missing. + lastModified: string | null; + // Presigned, expires in minutes. Null when missing. Display only: never store it. + url: string | null; +} + +// The key goes in the query, never the path: a valid S3 key such as `a/../b` would be normalized by the browser into a request for another key. +function storeObjectUrl(key: string): string { + return `/api/object-store/object?${new URLSearchParams({ key })}`; +} + +export async function getStoreEntries(): Promise { + const res = await authorizedFetch("/api/object-store"); + return (await res.json()) as StoreEntry[]; +} + +// Recorded objects only. +export async function renameObject(key: string, fileName: string): Promise { + await authorizedFetch(storeObjectUrl(key), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileName }), + }); +} + +// Permanent. Unchecked: cDb commits that reference the object lose its bytes. +export async function deleteObject(key: string): Promise { + await authorizedFetch(storeObjectUrl(key), { method: "DELETE" }); +} diff --git a/apps/examiner-dashboard/client/src/utils/objects.ts b/apps/examiner-dashboard/client/src/utils/objects.ts new file mode 100644 index 0000000..c0424a8 --- /dev/null +++ b/apps/examiner-dashboard/client/src/utils/objects.ts @@ -0,0 +1,43 @@ +// Upload policy for question objects. Mirrors server/routes/objects.rs (`KINDS` and the limits), so +// the dropzone rejects what the server would. The server sniffs the bytes and rejects anything else. + +export const MAX_FILE_BYTES = 10 * 1024 * 1024; +export const MAX_FILES_PER_REQUEST = 5; + +interface ObjectKind { + name: string; + // Browsers report some types under other names (`audio/x-m4a`, `audio/x-wav`), or none at all, so + // extensions match too. + contentTypes: string[]; + extensions: string[]; +} + +export const OBJECT_KINDS: ObjectKind[] = [ + { name: "PNG", contentTypes: ["image/png"], extensions: [".png"] }, + { name: "JPEG", contentTypes: ["image/jpeg"], extensions: [".jpg", ".jpeg"] }, + { name: "GIF", contentTypes: ["image/gif"], extensions: [".gif"] }, + { name: "WebP", contentTypes: ["image/webp"], extensions: [".webp"] }, + { name: "MP3", contentTypes: ["audio/mpeg", "audio/mp3"], extensions: [".mp3"] }, + { + name: "WAV", + contentTypes: ["audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave"], + extensions: [".wav"], + }, + { name: "Ogg", contentTypes: ["audio/ogg"], extensions: [".ogg", ".oga"] }, + { name: "FLAC", contentTypes: ["audio/flac", "audio/x-flac"], extensions: [".flac"] }, + { name: "M4A", contentTypes: ["audio/mp4", "audio/x-m4a"], extensions: [".m4a"] }, +]; + +// For the file picker and dropzone: a file matching any entry is accepted. +export const ACCEPT = OBJECT_KINDS.flatMap((kind) => [...kind.contentTypes, ...kind.extensions]); + +// "PNG, JPEG, ... or M4A". +export const KIND_NAMES = new Intl.ListFormat("en", { type: "disjunction" }).format( + OBJECT_KINDS.map((kind) => kind.name), +); + +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`; +} 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..e6f046d --- /dev/null +++ b/apps/examiner-dashboard/client/src/views/file-dragger.test.tsx @@ -0,0 +1,173 @@ +/* @vitest-environment jsdom */ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import type { RecordedObject } from "@/utils/fetch"; +import { camperbot } from "../../test-utils/session"; +import { formatBytes } from "@/utils/objects"; +import { FileDragger } from "./file-dragger.tsx"; + +vi.mock("@/utils/navigate"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +const diagramHash = "a".repeat(64); +const diagram: RecordedObject = { + sha256: diagramHash, + objectKey: `sha256/${diagramHash}.png`, + contentType: "image/png", + byteSize: 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: RecordedObject = { + ...diagram, + sha256: listeningHash, + objectKey: `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 objects behind /api/objects. `failUpload`: POST stores the +// first file, then fails. +function mockServer(initial: RecordedObject[], { failUpload = false } = {}) { + const stored = [...initial]; + const fetch = vi.fn((url: string, init?: RequestInit) => { + if (url === "/auth/session") { + return Promise.resolve(Response.json(camperbot)); + } + if (url === "/api/objects" && init?.method === "POST") { + const body = init.body as FormData; + const uploaded: RecordedObject[] = body.getAll("file").map((file, i) => ({ + sha256: String(i).repeat(64), + objectKey: `sha256/${String(i).repeat(64)}.png`, + contentType: "image/png", + byteSize: (file as File).size, + fileName: (file as File).name, + createdAt: "2026-11-06T10:00:00Z", + url: "https://bucket.example/new", + })); + if (failUpload) { + stored.unshift(...uploaded.slice(0, 1)); + return Promise.resolve( + new Response("b.png: object storage request failed. Stored before it: a.png", { + status: 502, + }), + ); + } + stored.unshift(...uploaded); + return Promise.resolve(Response.json(uploaded, { status: 201 })); + } + if (url === "/api/objects") { + 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" }); + + choose(screen, [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(); +}); + +function choose(screen: ReturnType, files: File[]) { + // Hidden from the accessibility tree: the dropzone is the accessible control. + const input = screen.container.querySelector("input[type=file]"); + fireEvent.input(input!, { target: { files } }); +} + +test("accepts types browsers report under other names, by extension", async () => { + mockServer([]); + const screen = render(); + await screen.findByText("No files yet."); + + choose(screen, [ + new File(["m4a"], "listening.m4a", { type: "audio/x-m4a" }), + new File(["wav"], "listening.wav", { type: "audio/x-wav" }), + new File(["svg"], "diagram.svg", { type: "image/svg+xml" }), + ]); + + expect(await screen.findByRole("button", { name: /Upload 2 files/ })).toBeEnabled(); + expect(screen.getByText(/diagram\.svg: file invalid type/)).toBeInTheDocument(); +}); + +test("after a failed upload, shows the error and lists what was stored", async () => { + mockServer([], { failUpload: true }); + const screen = render(); + await screen.findByText("No files yet."); + + choose(screen, [ + new File(["a"], "a.png", { type: "image/png" }), + new File(["b"], "b.png", { type: "image/png" }), + ]); + fireEvent.click(await screen.findByRole("button", { name: /Upload 2 files/ })); + + expect(await screen.findByText(/Stored before it: a\.png/)).toBeInTheDocument(); + expect(await screen.findByText("0".repeat(64))).toBeInTheDocument(); +}); + +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..89fe83a --- /dev/null +++ b/apps/examiner-dashboard/client/src/views/file-dragger.tsx @@ -0,0 +1,211 @@ +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 { getObjects, type RecordedObject, uploadObjects } from "@/utils/fetch"; +import { + ACCEPT, + formatBytes, + KIND_NAMES, + MAX_FILE_BYTES, + MAX_FILES_PER_REQUEST, +} from "@/utils/objects"; + +const OBJECTS_QUERY_KEY = ["objects"]; +// Presigned URLs last 15 minutes: refetch before previews break. In a background tab, the interval +// pauses and focus refetches once the list is stale. +const OBJECTS_REFRESH_MS = 10 * 60 * 1000; + +function Uploader() { + const queryClient = useQueryClient(); + const fileUpload = useFileUpload({ + maxFiles: MAX_FILES_PER_REQUEST, + maxFileSize: MAX_FILE_BYTES, + accept: ACCEPT, + }); + const upload = useMutation({ + mutationFn: uploadObjects, + onSuccess: () => { + fileUpload.clearFiles(); + }, + // After a failure too: files before the failed one were stored. + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: OBJECTS_QUERY_KEY }); + }, + }); + + const pending = fileUpload.acceptedFiles; + + return ( + + + + + + + + + Drag files here, or click to choose + + {KIND_NAMES}. Up to {MAX_FILES_PER_REQUEST} 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: RecordedObject }) { + if (file.contentType.startsWith("image/")) { + return {file.fileName}; + } + if (file.contentType.startsWith("audio/")) { + return