feat: implement dynamo insert with file upload
This commit is contained in:
1 parent
5cafc7e757
commit
1bccecdc72
3 files changed
+104
-50
No files matched your search
+22
-27
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user