11use super :: RequestInfo ;
2+ use crate :: app_config:: AppConfig ;
23use anyhow:: Context ;
34use aws_config:: BehaviorVersion ;
45use aws_sdk_s3:: presigning:: PresigningConfig ;
@@ -12,14 +13,38 @@ pub(super) async fn upload_to_s3<'a>(
1213 key : Cow < ' a , str > ,
1314) -> anyhow:: Result < String > {
1415 let config = & request. app_state . config ;
16+ let client = get_s3_client ( config) . await ;
17+ upload_to_s3_with_client ( config, & client, bucket, data, key) . await
18+ }
19+
20+ async fn upload_to_s3_with_client < ' a > (
21+ config : & AppConfig ,
22+ client : & aws_sdk_s3:: Client ,
23+ bucket : Option < Cow < ' a , str > > ,
24+ data : Cow < ' a , str > ,
25+ key : Cow < ' a , str > ,
26+ ) -> anyhow:: Result < String > {
1527 let bucket = bucket
1628 . as_deref ( )
1729 . or ( config. s3_bucket . as_deref ( ) )
1830 . ok_or_else ( || anyhow:: anyhow!( "S3 bucket not configured" ) ) ?;
1931
20- let client = get_s3_client ( config) . await ;
32+ let body_bytes = prepare_upload_body ( data. as_ref ( ) , config) . await ?;
33+
34+ client
35+ . put_object ( )
36+ . bucket ( bucket)
37+ . key ( key. as_ref ( ) )
38+ . body ( body_bytes. into ( ) )
39+ . send ( )
40+ . await
41+ . map_err ( |e| anyhow:: anyhow!( "Failed to upload to S3: {e}" ) ) ?;
2142
22- let body_bytes = if let Some ( stripped) = data. strip_prefix ( "file://" ) {
43+ Ok ( format ! ( "s3://{bucket}/{key}" ) )
44+ }
45+
46+ async fn prepare_upload_body ( data : & str , config : & AppConfig ) -> anyhow:: Result < Vec < u8 > > {
47+ if let Some ( stripped) = data. strip_prefix ( "file://" ) {
2348 let file_path = std:: path:: Path :: new ( stripped) ;
2449 // Security check: ensure the file is within the web root or allowed paths
2550 let web_root = & config. web_root ;
@@ -33,7 +58,7 @@ pub(super) async fn upload_to_s3<'a>(
3358 log:: error!( "Failed to read file {}: {}" , full_path. display( ) , e) ;
3459 e
3560 } )
36- . with_context ( || format ! ( "Unable to read file {}" , full_path. display( ) ) ) ?
61+ . with_context ( || format ! ( "Unable to read file {}" , full_path. display( ) ) )
3762 } else {
3863 // Assume base64
3964 use base64:: Engine ;
@@ -43,19 +68,8 @@ pub(super) async fn upload_to_s3<'a>(
4368 log:: error!( "Base64 decode failed: {e}" ) ;
4469 e
4570 } )
46- . context ( "Invalid base64 data" ) ?
47- } ;
48-
49- client
50- . put_object ( )
51- . bucket ( bucket)
52- . key ( key. as_ref ( ) )
53- . body ( body_bytes. into ( ) )
54- . send ( )
55- . await
56- . map_err ( |e| anyhow:: anyhow!( "Failed to upload to S3: {e}" ) ) ?;
57-
58- Ok ( format ! ( "s3://{bucket}/{key}" ) )
71+ . context ( "Invalid base64 data" )
72+ }
5973}
6074
6175pub ( super ) async fn get_from_s3 < ' a > (
@@ -64,13 +78,21 @@ pub(super) async fn get_from_s3<'a>(
6478 key : Cow < ' a , str > ,
6579) -> anyhow:: Result < String > {
6680 let config = & request. app_state . config ;
81+ let client = get_s3_client ( config) . await ;
82+ get_from_s3_with_client ( config, & client, bucket, key) . await
83+ }
84+
85+ async fn get_from_s3_with_client < ' a > (
86+ config : & AppConfig ,
87+ client : & aws_sdk_s3:: Client ,
88+ bucket : Option < Cow < ' a , str > > ,
89+ key : Cow < ' a , str > ,
90+ ) -> anyhow:: Result < String > {
6791 let bucket = bucket
6892 . as_deref ( )
6993 . or ( config. s3_bucket . as_deref ( ) )
7094 . ok_or_else ( || anyhow:: anyhow!( "S3 bucket not configured" ) ) ?;
7195
72- let client = get_s3_client ( config) . await ;
73-
7496 let presigning_config = PresigningConfig :: expires_in ( Duration :: from_secs ( 3600 ) ) ?;
7597
7698 let presigned_request = client
@@ -107,3 +129,66 @@ async fn get_s3_client(config: &crate::app_config::AppConfig) -> aws_sdk_s3::Cli
107129 let sdk_config = loader. load ( ) . await ;
108130 aws_sdk_s3:: Client :: new ( & sdk_config)
109131}
132+
133+ #[ cfg( test) ]
134+ mod tests {
135+ use super :: * ;
136+ use crate :: app_config:: tests:: test_config;
137+
138+ #[ tokio:: test]
139+ async fn test_prepare_upload_body_base64 ( ) {
140+ let config = test_config ( ) ;
141+ let data = "SGVsbG8gV29ybGQ=" ; // "Hello World"
142+ let result = prepare_upload_body ( data, & config) . await . unwrap ( ) ;
143+ assert_eq ! ( result, b"Hello World" ) ;
144+ }
145+
146+ #[ tokio:: test]
147+ async fn test_prepare_upload_body_invalid_base64 ( ) {
148+ let config = test_config ( ) ;
149+ let data = "InvalidBase64!" ;
150+ let result = prepare_upload_body ( data, & config) . await ;
151+ assert ! ( result. is_err( ) ) ;
152+ }
153+
154+ #[ tokio:: test]
155+ async fn test_prepare_upload_body_file_security ( ) {
156+ let config = test_config ( ) ;
157+ // Try to access a file outside the web root (assuming /tmp is outside)
158+ // Note: test_config uses current dir as web_root usually, or a temp dir.
159+ // We need to construct a path that attempts directory traversal or absolute path outside.
160+ let data = "file:///etc/passwd" ;
161+ let result = prepare_upload_body ( data, & config) . await ;
162+ assert ! ( result. is_err( ) ) ;
163+ assert ! ( result
164+ . unwrap_err( )
165+ . to_string( )
166+ . contains( "Security violation" ) ) ;
167+ }
168+
169+ #[ tokio:: test]
170+ async fn test_get_from_s3_presigned ( ) {
171+ let mut config = test_config ( ) ;
172+ config. s3_bucket = Some ( "my-bucket" . to_string ( ) ) ;
173+ config. s3_region = Some ( "us-east-1" . to_string ( ) ) ;
174+ config. s3_access_key = Some ( "test" . to_string ( ) ) ;
175+ config. s3_secret_key = Some ( "test" . to_string ( ) ) ;
176+
177+ // Create a client that doesn't actually connect but is valid enough for presigning
178+ // Presigning is a local operation for the most part, but it needs credentials.
179+ let client = get_s3_client ( & config) . await ;
180+
181+ let url = get_from_s3_with_client (
182+ & config,
183+ & client,
184+ None ,
185+ Cow :: Borrowed ( "my-file.txt" ) ,
186+ )
187+ . await
188+ . unwrap ( ) ;
189+
190+ assert ! ( url. contains( "my-bucket" ) ) ;
191+ assert ! ( url. contains( "my-file.txt" ) ) ;
192+ assert ! ( url. contains( "X-Amz-Signature" ) ) ;
193+ }
194+ }
0 commit comments