diff --git a/backend/src/main.rs b/backend/src/main.rs index 350f621..22e18b8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -88,7 +88,14 @@ async fn app() -> Router { Router::new() .route("/upload", post(upload)) - .route("/evaluations/:evaluation_id", get(get_evaluation)) + .route( + "/evaluations/:evaluation_id", + get(get_evaluation).delete(delete_resume_and_evaluation), + ) + .route( + "/evaluations/:evaluation_id/download_resume", + get(download_resume), + ) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) @@ -125,6 +132,69 @@ async fn upload() -> Result { Ok((StatusCode::ACCEPTED, Json(result)).into_response()) } +async fn delete_resume_and_evaluation( + Path(evaluation_id): Path, +) -> Result { + let s3_context = s3::S3Context::new().await; + + let resume_key = format!("resumes/{}", evaluation_id.to_string()); + let evaluation_key = format!("results/{}", evaluation_id.to_string()); + + tracing::info!("Checking if object exists: {}", resume_key); + + let resume_exists = s3_context.object_exists(&resume_key).await.map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to check resume status: {err}"), + ) + })?; + + if !resume_exists { + return Err(( + StatusCode::NOT_FOUND, + format!("resume {} not found", evaluation_id), + )); + } + + tracing::info!("Checking if object exists: {}", evaluation_key); + + let evaluation_exists = s3_context + .object_exists(&evaluation_key) + .await + .map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to check evaluation status: {err}"), + ) + })?; + + if !evaluation_exists { + return Err(( + StatusCode::NOT_FOUND, + format!("evaluation {} not found", evaluation_id), + )); + } + + s3_context.delete_object(&resume_key).await.map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to delete resume: {err}"), + ) + })?; + + s3_context + .delete_object(&evaluation_key) + .await + .map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to delete evaluation: {err}"), + ) + })?; + + Ok((StatusCode::ACCEPTED, "OK").into_response()) +} + async fn get_evaluation( Path(evaluation_id): Path, ) -> Result { @@ -160,6 +230,48 @@ async fn get_evaluation( Ok((StatusCode::ACCEPTED, Json(evaluation)).into_response()) } +async fn download_resume( + Path(evaluation_id): Path, +) -> Result { + let s3_context = s3::S3Context::new().await; + + let key = format!("resumes/{}", evaluation_id.to_string()); + + tracing::info!("Checking if object exists: {}", key); + + let object_exists = s3_context.object_exists(&key).await.map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to check resume status: {err}"), + ) + })?; + + if !object_exists { + return Err(( + StatusCode::NOT_FOUND, + format!("resume {} not found", evaluation_id), + )); + } + + let filename = format!("resume-{}.pdf", evaluation_id); + + let presigned_url = s3_context + .get_object_presigned_url(&key, &filename) + .await + .map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to get presigned url: {err}"), + ) + })?; + + let result = json!({ + "download_url": presigned_url, + }); + + Ok((StatusCode::ACCEPTED, Json(result)).into_response()) +} + #[tokio::main] async fn main() { logging::setup_logging(); diff --git a/backend/src/s3.rs b/backend/src/s3.rs index 5998d79..8e73bf4 100644 --- a/backend/src/s3.rs +++ b/backend/src/s3.rs @@ -4,7 +4,8 @@ use anyhow::{Context, Result}; use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::ByteStream; -const PUT_EXPIRES_IN: Duration = Duration::from_secs(30 * 60); +const GET_EXPIRES_IN: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours +const PUT_EXPIRES_IN: Duration = Duration::from_secs(30 * 60); // 30 minutes #[derive(Debug, Clone)] pub struct S3Context { @@ -40,6 +41,24 @@ impl S3Context { Ok(res.is_ok()) } + /// Generate presigned URL for downloading from S3. + pub async fn get_object_presigned_url( + &self, + key: &String, + filename: &String, + ) -> Result { + let presigned_req = &self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .response_content_disposition(format!("attachment; filename=\"{}\"", filename)) + .presigned(PresigningConfig::expires_in(GET_EXPIRES_IN)?) + .await?; + + Ok(presigned_req.uri().to_string()) + } + /// Generate presigned URL for uploading to S3. pub async fn put_object_presigned_url(&self, key: &String) -> Result { let presigned_req = &self @@ -88,4 +107,19 @@ impl S3Context { Ok(()) } + + /// Delete S3 object. + pub async fn delete_object(&self, key: &str) -> Result<()> { + self.client + .delete_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .with_context(|| { + format!("Failed to delete {} from S3 bucket {}.", key, &self.bucket) + })?; + + Ok(()) + } }