Implement template student portal #62

Merged
BobdaFett merged 21 commits from feat/value-display into main 2026-03-02 21:38:31 +00:00
3 changed files with 104 additions and 50 deletions
Showing only changes of commit 1bccecdc72 - Show all commits

No files matched your search

+22 -27
View File
@@ -1,3 +1,4 @@
use crate::pages::api::files::upload_file;
use leptos::html::{Input, Label}; use leptos::html::{Input, Label};
use leptos::logging::debug_log; use leptos::logging::debug_log;
use leptos::prelude::*; use leptos::prelude::*;
@@ -5,13 +6,9 @@ use leptos::web_sys::{File, FormData};
use leptos_icons::Icon; use leptos_icons::Icon;
use leptos_oidc::AuthSignal; use leptos_oidc::AuthSignal;
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options}; use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
use crate::pages::api::files::upload_file;
#[component] #[component]
pub fn FileDrop( pub fn FileDrop(#[prop(into)] name: String, #[prop(into)] form_id: String) -> impl IntoView {
#[prop(into)] name: String,
#[prop(into)] form_id: String,
) -> impl IntoView {
let zone_ref = NodeRef::<Label>::new(); let zone_ref = NodeRef::<Label>::new();
let input_ref = NodeRef::<Input>::new(); let input_ref = NodeRef::<Input>::new();
@@ -23,9 +20,7 @@ pub fn FileDrop(
let upload_action = StoredValue::new(Action::new(|form_data: &FormData| { let upload_action = StoredValue::new(Action::new(|form_data: &FormData| {
let form_data = form_data.clone(); let form_data = form_data.clone();
async move { async move { upload_file(form_data.into()).await }
upload_file(form_data.into()).await
}
})); }));
let uploading = upload_action.get_value().pending(); let uploading = upload_action.get_value().pending();
@@ -34,20 +29,26 @@ pub fn FileDrop(
let form_data = FormData::new().unwrap(); let form_data = FormData::new().unwrap();
files.iter().for_each(|file| { files.iter().for_each(|file| {
let file_name = format!("{}_{}", name.get_value(), file.name()); form_data
.append_with_blob_and_filename(&name.get_value(), file, &file.name())
form_data.append_with_blob_and_filename(&name.get_value(), file, &file_name)
.unwrap(); .unwrap();
}); });
form_data.append_with_str("form_id", form_id.get_value().as_str()).unwrap(); form_data
.append_with_str("form_id", form_id.get_value().as_str())
.unwrap();
let token = auth.try_with(|a| let token = auth
a.authenticated().map(|authenticated| authenticated.access_token()) .try_with(|a| {
).flatten(); a.authenticated()
.map(|authenticated| authenticated.access_token())
})
.flatten();
if let Some(access_token) = token { if let Some(access_token) = token {
form_data.append_with_str("access_token", access_token.as_str()).unwrap() form_data
.append_with_str("access_token", access_token.as_str())
.unwrap()
} }
// Upload file to server. // Upload file to server.
@@ -55,15 +56,13 @@ pub fn FileDrop(
}); });
let UseDropZoneReturn { let UseDropZoneReturn {
is_over_drop_zone, is_over_drop_zone, ..
..
} = use_drop_zone_with_options( } = use_drop_zone_with_options(
zone_ref, zone_ref,
UseDropZoneOptions::default() UseDropZoneOptions::default()
.on_over(move |_| hovering.set(true)) .on_over(move |_| hovering.set(true))
.on_drop(move |ev| { .on_drop(move |ev| {
let file = ev.files.first() let file = ev.files.first().unwrap();
.unwrap();
// Update file list // Update file list
file_name_list.update(|list| { file_name_list.update(|list| {
@@ -79,7 +78,8 @@ pub fn FileDrop(
// This runs when users select a file by clicking on the element. We aren't going // This runs when users select a file by clicking on the element. We aren't going
// to link the file list with this input except to update it with new information. // to link the file list with this input except to update it with new information.
let files = input_ref.get() let files = input_ref
.get()
.and_then(|input| input.files()) .and_then(|input| input.files())
.map(|list| { .map(|list| {
(0..list.length()) (0..list.length())
@@ -122,12 +122,7 @@ pub fn FileDrop(
</div> </div>
<div>"Drop a file here to upload"</div> <div>"Drop a file here to upload"</div>
<div class="text-sm text-gray-400">"Or click to select a file"</div> <div class="text-sm text-gray-400">"Or click to select a file"</div>
<input <input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
node_ref=input_ref
type="file"
class="hidden"
on:change=on_input_change
/>
</label> </label>
<For each=move || file_name_list.get() key=|file| file.clone() let:file> <For each=move || file_name_list.get() key=|file| file.clone() let:file>
<div class="flex flex-row relative"> <div class="flex flex-row relative">
+79 -20
View File
@@ -1,13 +1,13 @@
use leptos::prelude::*; use leptos::logging::debug_log;
use server_fn::codec::{MultipartFormData, MultipartData}; use leptos::prelude::*;
use leptos::logging::debug_log; use server_fn::codec::{MultipartData, MultipartFormData};
#[server(input = MultipartFormData)] #[server(input = MultipartFormData)]
pub async fn upload_file( pub async fn upload_file(data: MultipartData) -> Result<(), ServerFnError> {
data: MultipartData use super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
) -> Result<(), ServerFnError> {
use crate::pages::api::tokens::validate_and_get_token_info; use crate::pages::api::tokens::validate_and_get_token_info;
use crate::utils::server::create_aws_config; use crate::utils::server::create_aws_config;
use aws_sdk_dynamodb::types::AttributeValue;
let mut multipart = data.into_inner().unwrap(); let mut multipart = data.into_inner().unwrap();
@@ -15,6 +15,7 @@ pub async fn upload_file(
let mut file_bytes = Vec::<u8>::new(); let mut file_bytes = Vec::<u8>::new();
let mut form_id = String::new(); let mut form_id = String::new();
let mut access_token = String::new(); let mut access_token = String::new();
let mut input_name = String::new();
while let Ok(Some(field)) = multipart.next_field().await { while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or_default(); let field_name = field.name().unwrap_or_default();
@@ -26,46 +27,104 @@ pub async fn upload_file(
// NOTE: If there are multiple files put here the function will only upload the last // NOTE: If there are multiple files put here the function will only upload the last
// one in the data. // one in the data.
// We should likely use chunk streaming in the future, for safety. // We should likely use chunk streaming in the future, for safety.
file_name = field.file_name() input_name = field
.name()
.ok_or(ServerFnError::new("Couldn't find input name"))?
.to_owned();
file_name = field
.file_name()
.ok_or(ServerFnError::new("Couldn't find file name"))? .ok_or(ServerFnError::new("Couldn't find file name"))?
.to_owned(); .to_owned();
file_bytes = field.bytes().await?.to_vec(); file_bytes = field.bytes().await?.to_vec();
}, }
} }
} }
if access_token.is_empty() { return Err(ServerFnError::new("Missing access token")); } if access_token.is_empty() {
if form_id.is_empty() { return Err(ServerFnError::new("Missing form ID")); } return Err(ServerFnError::new("Missing access token"));
if file_name.is_empty() { return Err(ServerFnError::new("Missing file")); } }
if form_id.is_empty() {
return Err(ServerFnError::new("Missing form ID"));
}
if file_name.is_empty() {
return Err(ServerFnError::new("Missing file"));
}
if input_name.is_empty() {
return Err(ServerFnError::new("Missing input file"));
}
let user_claims = validate_and_get_token_info(access_token).await?; let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject; let subject = user_claims.subject;
let key = format!("{form_id}/{subject}/{file_name}"); let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
debug_log!("Adding file to S3: {:?}", key); debug_log!("Adding file to S3: {:?}", key);
let client = aws_sdk_s3::Client::new(&create_aws_config().await); let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
client s3_client
.put_object() .put_object()
.bucket("leptos-scholarships") .bucket(S3_BUCKET_NAME)
.key(key) .key(&key)
.body(file_bytes.into()) .body(file_bytes.into())
.send() .send()
.await .await
.map(|_| ()) .map(|_| ())
.map_err(ServerFnError::from) .map_err(|e| ServerFnError::new(format!("Couldn't put file to S3: {}", e.to_string())))?;
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
// This will allow the server to quickly return a list of all files that a specific user has
// uploaded, or a list of all files submitted for a specific question with their keys for
// additional access.
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
let dynamo_result = dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.item(
"HK".to_string(),
AttributeValue::S(format!("STUDENT#{subject}")),
)
.item(
"SK".to_string(),
AttributeValue::S(format!("FILE#{form_id}#{input_name}")),
)
.item("file_name".to_string(), AttributeValue::S(file_name))
.item("file_key".to_string(), AttributeValue::S(key.clone()))
.send()
.await
.map(|_| ())
.map_err(|e| ServerFnError::new(format!("Couldn't put file to Dynamo: {}", e.to_string())));
if let Err(_) = dynamo_result {
// Roll back file insertion to S3. Only attempted on Dynamo insertion failure.
s3_client
.delete_object()
.bucket("leptos-scholarships")
.key(&key)
.send()
.await
.map(|_| ())
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't roll back S3 operation: {}",
e.to_string()
))
})
} else {
dynamo_result
}
} }
#[server] #[server]
pub async fn delete_file( pub async fn delete_file(
access_token: String, access_token: String,
form_id: String, form_id: String,
file_name: String file_name: String,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
use crate::utils::server::create_aws_config; use super::S3_BUCKET_NAME;
use crate::pages::api::tokens::validate_and_get_token_info; use crate::pages::api::tokens::validate_and_get_token_info;
use crate::utils::server::create_aws_config;
let user_claims = validate_and_get_token_info(access_token).await?; let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject; let subject = user_claims.subject;
@@ -76,7 +135,7 @@ pub async fn delete_file(
client client
.delete_object() .delete_object()
.bucket("leptos-scholarhips") .bucket(S3_BUCKET_NAME)
.key(key) .key(key)
.send() .send()
.await .await
+3 -3
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*;
use crate::components::FileDrop; use crate::components::FileDrop;
use leptos::prelude::*;
#[component] #[component]
pub fn AdditionalPage() -> impl IntoView { pub fn AdditionalPage() -> impl IntoView {
@@ -10,7 +10,7 @@ pub fn AdditionalPage() -> impl IntoView {
"This is just a placeholder for now. We'll show redirect buttons to each form." "This is just a placeholder for now. We'll show redirect buttons to each form."
</div> </div>
<div class="text-lg">"Here's a testing file input!"</div> <div class="text-lg">"Here's a testing file input!"</div>
<FileDrop name="testing" form_id="student_additional"/> <FileDrop name="testing" form_id="student_additional" />
</div> </div>
} }
} }