From f8d399f9ae317fd3eae5582b2e7f9ec94ac48dc6 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 9 Apr 2026 23:49:31 -0400 Subject: [PATCH] feat: create file download capability --- Cargo.lock | 202 +++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/pages/api/students.rs | 68 ++++++++--- src/pages/provider_applicants.rs | 72 +++++++++-- 4 files changed, 321 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93a5549..50a500c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -739,6 +756,15 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "camino" version = "1.2.2" @@ -789,6 +815,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.57" @@ -895,6 +931,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -1173,6 +1215,12 @@ dependencies = [ "syn", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "der" version = "0.6.1" @@ -1359,6 +1407,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2237,6 +2295,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -2683,6 +2750,12 @@ dependencies = [ "tachys", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.182" @@ -2725,6 +2798,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2", +] + [[package]] name = "macros" version = "0.1.0" @@ -2795,6 +2877,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -2958,6 +3050,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -3016,6 +3118,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "potential_utf" version = "0.1.4" @@ -3031,6 +3139,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3621,6 +3735,7 @@ dependencies = [ "chrono", "console_error_panic_hook", "csv", + "futures", "icondata", "indexmap", "jsonwebtoken 10.3.0", @@ -3640,6 +3755,7 @@ dependencies = [ "traits", "uuid", "wasm-bindgen", + "zip", ] [[package]] @@ -3931,6 +4047,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4177,6 +4299,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4464,6 +4587,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -5311,8 +5440,81 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.1", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index db5a983..9102aac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ csv = "1.4.0" leptos-use = { version = "0.18.3", features = ["use_drop_zone"] } jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true } reqwest = { version = "0.13.2", features = ["json"] } +zip = { version = "8.5.1", optional = true } +futures = { version = "0.3.32", optional = true } [features] default = ["ssr"] @@ -51,6 +53,8 @@ ssr = [ "dep:aws-config", "dep:serde_dynamo", "dep:jsonwebtoken", + "dep:zip", + "dep:futures", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 29291e9..60c029b 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; use std::collections::HashMap; +use std::io::Write; #[cfg(feature = "ssr")] mod imports { @@ -12,6 +13,7 @@ mod imports { pub use aws_sdk_dynamodb::types::AttributeValue; pub use leptos::logging::{debug_log, error}; pub use std::collections::HashMap; + pub use zip::write::SimpleFileOptions; } #[server(input = Json)] @@ -211,7 +213,7 @@ pub async fn get_completed_students( Ok(output) } -/// # Get Student File API +/// # Get File by Key API /// This function gets a file from the corresponding S3 file key. If the subject found from the /// `access_token` does not match the owner of the requested file, the requesting user must have /// provider-level access or higher. @@ -221,7 +223,7 @@ pub async fn get_completed_students( /// if the requesting user does not have the correct permissions, it will return a message indicating /// such. If the file is not found, an error containing "File not found" will be returned. #[server] -pub async fn get_student_file( +pub async fn get_file_by_key( access_token: String, file_key: String, ) -> Result, ServerFnError> { @@ -262,29 +264,18 @@ pub async fn get_student_file( Ok(bytes.to_vec()) } -/// # Batch Get Student Files API -/// Gets all indicated files and returns a single `.zip` file containing all of them. They will be -/// organized based on their file key. -/// -/// This is more efficient than calling the [`get_student_file`] function as it makes use of S3's -/// `batch_get_object` function. -#[server] -pub async fn batch_get_student_files() -> Result, ServerFnError> { - todo!() -} - /// # Get Student File Names API /// Gets a list of file names given a student's ID and the corresponding input ID from the forms. /// These file names can be used to construct a file key for S3. /// /// This API is only accessible by provider-level users. #[server] -pub async fn get_student_file_names( +pub async fn get_student_files( access_token: String, student_id: String, form_name: String, question_id: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let claims = validate_and_get_token_info(access_token).await?; @@ -296,7 +287,7 @@ pub async fn get_student_file_names( let client = create_dynamo_client().await; - client + let file_keys = client .query() .table_name(MAIN_TABLE_NAME) .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) @@ -319,9 +310,52 @@ pub async fn get_student_file_names( items .into_iter() - .filter_map(|item| item.get("SK")?.as_s().ok().cloned()) + .filter_map(|item| item.get("file_key")?.as_s().ok().cloned()) .collect::>() + })?; + + debug_log!("Await student file futures..."); + let futures: Vec<_> = file_keys + .into_iter() + .map(|file_key| async move { + debug_log!("Requesting file from API with key {file_key}"); + // Get all files from S3. This is a batch operation. + let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await); + let file_output = s3_client + .get_object() + .bucket("leptos-scholarships") + .key(&file_key) + .send() + .await + .ok()?; + + let bytes = file_output.body.collect().await.ok()?.to_vec(); + + Some((file_key, bytes)) }) + .collect(); + + let results = futures::future::join_all(futures) + .await + .into_iter() + .flatten() + .collect::)>>(); + + let cur = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cur); + + debug_log!("Writing {} files to zip file...", results.len()); + for (key, file_bytes) in results { + writer + .start_file(key.clone(), SimpleFileOptions::default()) + .map_err(ServerFnError::new)?; + writer.write_all(&file_bytes).map_err(ServerFnError::new)?; + } + + debug_log!("Getting finished file"); + let finished = writer.finish().map_err(ServerFnError::new)?; + + Ok(finished.into_inner()) } /// # Get All Input Files API diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c3a5aba..c1043c5 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -2,14 +2,14 @@ use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{ - BatchGetStudentFiles, GetStudentFile, GetStudentFileNames, get_all_input_files, - get_all_student_data, get_student_data, get_student_file_names, -}; +use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data}; +use base64::Engine; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; use leptos::logging::debug_log; use leptos::prelude::*; +use leptos::wasm_bindgen::JsCast; +use leptos::web_sys::HtmlAnchorElement; use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; use leptos_router::hooks::{query_signal, use_navigate}; @@ -501,9 +501,12 @@ fn ApplicantsStudentListView( // flex container. view! {
"Error checking student eligibility: "{error_msg}
@@ -550,9 +553,11 @@ fn ApplicantsStudentListView( #[component] fn StudentInformationDialog( + #[prop(into)] scholarship_id: Signal, #[prop(into)] student_id: Signal>, - // #[prop(into)] scholarship_info: StoredValue, #[prop(into)] visible: Signal, + #[prop(into)] fafsa_required: Signal, + #[prop(into)] essay_required: Signal, #[prop(into)] on_close: Callback<()>, ) -> impl IntoView { // This component needs to just display all student information. @@ -560,6 +565,9 @@ fn StudentInformationDialog( // requested an essay or financial information. let dialog_ref = NodeRef::::new(); + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token()))); //#region Schema Definitions @@ -635,13 +643,50 @@ fn StudentInformationDialog( // We want to get the student's uploaded files - only the ones that are relevant to the current // scholarship, meaning their essays and/or FAFSA by request. + let get_student_files = ServerAction::::new(); + let get_buttons_disabled = get_student_files.pending(); let on_click_fafsa = move |_| { // We need to get the student's file names/keys and then tell the server that we want to // download them. - let fafsa_key = format!("financial_info/user_id/fafsa/file_name"); - debug_log!("Getting FAFSA file with key {fafsa_key}"); + get_student_files.dispatch(GetStudentFiles { + access_token: access_token.get().unwrap_or_default(), + student_id: student_id.get().unwrap_or_default(), + form_name: "financial_info".to_string(), + question_id: "fafsa".to_string(), + }); }; + let on_click_essay = move |_| { + debug_log!("Getting essay files"); + get_student_files.dispatch(GetStudentFiles { + access_token: access_token.get().unwrap_or_default(), + student_id: student_id.get().unwrap_or_default(), + form_name: "scholarship_essays".to_string(), + question_id: scholarship_id.get(), + }); + }; + + Effect::new(move || { + if let Some(Ok(file)) = get_student_files.value().get() { + let base64 = base64::engine::general_purpose::STANDARD.encode(file); + let data_url = format!("data:application/zip;base64,{}", base64); + // Create a base64 link and open it. + let a: HtmlAnchorElement = document() + .create_element("a") + .expect("Failed to create anchor element") + .dyn_into() + .unwrap(); + + a.set_download("testing.zip"); + a.set_href(&data_url); + document().body().unwrap().append_child(&a).unwrap(); + a.click(); + a.remove(); + + get_student_files.clear(); + } + }); + view! {
- "Get FAFSA" +
+ + + "Get FAFSA File(s)" + + + + + "Get Essay File(s)" + + +
{move || Suspend::new(async move { get_student_data( student_id.get().unwrap_or_default(),