@@ -25,6 +25,7 @@ async fn fs_endpoints_require_auth() {
2525 "/api/fs/remove" ,
2626 "/api/fs/rename" ,
2727 "/api/fs/temp" ,
28+ "/api/fs/upload" ,
2829 "/api/fs/image-base64" ,
2930 "/api/fs/fetch-remote-image" ,
3031 "/api/fs/zip" ,
@@ -775,3 +776,178 @@ async fn path_traversal_rejected() {
775776 // Should be rejected (400 bad request)
776777 assert_eq ! ( resp. status( ) , StatusCode :: BAD_REQUEST ) ;
777778}
779+
780+ // ===========================================================================
781+ // /api/fs/upload — multipart upload
782+ // ===========================================================================
783+
784+ struct UploadMultipart {
785+ boundary : String ,
786+ parts : Vec < u8 > ,
787+ }
788+
789+ impl UploadMultipart {
790+ fn new ( ) -> Self {
791+ Self {
792+ boundary : "----TestBoundaryFsUpload9XyZ" . to_owned ( ) ,
793+ parts : Vec :: new ( ) ,
794+ }
795+ }
796+
797+ fn add_text ( mut self , name : & str , value : & str ) -> Self {
798+ self . parts
799+ . extend_from_slice ( format ! ( "--{}\r \n " , self . boundary) . as_bytes ( ) ) ;
800+ self . parts
801+ . extend_from_slice ( format ! ( "Content-Disposition: form-data; name=\" {name}\" \r \n \r \n " ) . as_bytes ( ) ) ;
802+ self . parts . extend_from_slice ( value. as_bytes ( ) ) ;
803+ self . parts . extend_from_slice ( b"\r \n " ) ;
804+ self
805+ }
806+
807+ fn add_file ( mut self , name : & str , filename : & str , mime : & str , data : & [ u8 ] ) -> Self {
808+ self . parts
809+ . extend_from_slice ( format ! ( "--{}\r \n " , self . boundary) . as_bytes ( ) ) ;
810+ self . parts . extend_from_slice (
811+ format ! ( "Content-Disposition: form-data; name=\" {name}\" ; filename=\" {filename}\" \r \n " ) . as_bytes ( ) ,
812+ ) ;
813+ self . parts
814+ . extend_from_slice ( format ! ( "Content-Type: {mime}\r \n \r \n " ) . as_bytes ( ) ) ;
815+ self . parts . extend_from_slice ( data) ;
816+ self . parts . extend_from_slice ( b"\r \n " ) ;
817+ self
818+ }
819+
820+ fn build ( mut self ) -> ( String , Vec < u8 > ) {
821+ self . parts
822+ . extend_from_slice ( format ! ( "--{}--\r \n " , self . boundary) . as_bytes ( ) ) ;
823+ let content_type = format ! ( "multipart/form-data; boundary={}" , self . boundary) ;
824+ ( content_type, self . parts )
825+ }
826+ }
827+
828+ fn upload_request ( content_type : & str , body : Vec < u8 > , token : & str , csrf : & str ) -> axum:: http:: Request < axum:: body:: Body > {
829+ let content_length = body. len ( ) ;
830+ axum:: http:: Request :: builder ( )
831+ . method ( "POST" )
832+ . uri ( "/api/fs/upload" )
833+ . header ( "content-type" , content_type)
834+ . header ( "content-length" , content_length)
835+ . header ( "authorization" , format ! ( "Bearer {token}" ) )
836+ . header ( "x-csrf-token" , csrf)
837+ . header ( "cookie" , format ! ( "aionui-csrf-token={csrf}" ) )
838+ . body ( axum:: body:: Body :: from ( body) )
839+ . unwrap ( )
840+ }
841+
842+ #[ tokio:: test]
843+ async fn upload_accepts_small_png_and_returns_readable_path ( ) {
844+ let ( mut app, services) = build_app ( ) . await ;
845+ let ( token, csrf) = setup_and_login ( & mut app, & services, "admin" , "StrongP@ss1" ) . await ;
846+
847+ // Minimal valid 1x1 PNG (67 bytes).
848+ let png_bytes: Vec < u8 > = vec ! [
849+ 0x89 , 0x50 , 0x4E , 0x47 , 0x0D , 0x0A , 0x1A , 0x0A , 0x00 , 0x00 , 0x00 , 0x0D , 0x49 , 0x48 , 0x44 , 0x52 , 0x00 , 0x00 ,
850+ 0x00 , 0x01 , 0x00 , 0x00 , 0x00 , 0x01 , 0x08 , 0x02 , 0x00 , 0x00 , 0x00 , 0x90 , 0x77 , 0x53 , 0xDE , 0x00 , 0x00 , 0x00 ,
851+ 0x0C , 0x49 , 0x44 , 0x41 , 0x54 , 0x08 , 0xD7 , 0x63 , 0xF8 , 0xCF , 0xC0 , 0x00 , 0x00 , 0x00 , 0x02 , 0x00 , 0x01 , 0xE2 ,
852+ 0x21 , 0xBC , 0x33 , 0x00 , 0x00 , 0x00 , 0x00 , 0x49 , 0x45 , 0x4E , 0x44 , 0xAE , 0x42 , 0x60 , 0x82 ,
853+ ] ;
854+
855+ let conv_id = format ! ( "conv-upload-{}" , std:: process:: id( ) ) ;
856+ let ( content_type, body) = UploadMultipart :: new ( )
857+ . add_file ( "file" , "paste.png" , "image/png" , & png_bytes)
858+ . add_text ( "conversation_id" , & conv_id)
859+ . build ( ) ;
860+
861+ let req = upload_request ( & content_type, body, & token, & csrf) ;
862+ let resp = app. oneshot ( req) . await . unwrap ( ) ;
863+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
864+
865+ let json = body_json ( resp) . await ;
866+ assert_eq ! ( json[ "success" ] , true ) ;
867+ let path = json[ "data" ] . as_str ( ) . expect ( "data should be a string path" ) ;
868+ let p = std:: path:: Path :: new ( path) ;
869+ assert ! ( p. is_absolute( ) , "returned path must be absolute: {path}" ) ;
870+ assert_eq ! ( p. file_name( ) . unwrap( ) . to_string_lossy( ) , "paste.png" ) ;
871+ // conversation_id routing produced a sub-directory of that name.
872+ assert_eq ! ( p. parent( ) . unwrap( ) . file_name( ) . unwrap( ) . to_string_lossy( ) , conv_id) ;
873+ // File contents must match what we uploaded.
874+ let on_disk = std:: fs:: read ( p) . expect ( "uploaded file should be readable" ) ;
875+ assert_eq ! ( on_disk, png_bytes) ;
876+
877+ // Cleanup.
878+ let _ = std:: fs:: remove_file ( p) ;
879+ let _ = std:: fs:: remove_dir ( p. parent ( ) . unwrap ( ) ) ;
880+ }
881+
882+ #[ tokio:: test]
883+ async fn upload_uses_content_disposition_filename_when_file_name_missing ( ) {
884+ let ( mut app, services) = build_app ( ) . await ;
885+ let ( token, csrf) = setup_and_login ( & mut app, & services, "admin" , "StrongP@ss1" ) . await ;
886+
887+ let bytes = b"hello upload" . to_vec ( ) ;
888+ let unique = format ! ( "dispo-{}.bin" , std:: process:: id( ) ) ;
889+ let ( content_type, body) = UploadMultipart :: new ( )
890+ . add_file ( "file" , & unique, "application/octet-stream" , & bytes)
891+ . build ( ) ;
892+
893+ let req = upload_request ( & content_type, body, & token, & csrf) ;
894+ let resp = app. oneshot ( req) . await . unwrap ( ) ;
895+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
896+
897+ let json = body_json ( resp) . await ;
898+ let path = json[ "data" ] . as_str ( ) . unwrap ( ) ;
899+ assert ! ( path. ends_with( & unique) ) ;
900+ let _ = std:: fs:: remove_file ( path) ;
901+ }
902+
903+ #[ tokio:: test]
904+ async fn upload_prefers_explicit_file_name_field_over_dispo ( ) {
905+ let ( mut app, services) = build_app ( ) . await ;
906+ let ( token, csrf) = setup_and_login ( & mut app, & services, "admin" , "StrongP@ss1" ) . await ;
907+
908+ let bytes = b"pref" . to_vec ( ) ;
909+ let explicit = format ! ( "explicit-{}.bin" , std:: process:: id( ) ) ;
910+ let ( content_type, body) = UploadMultipart :: new ( )
911+ . add_file ( "file" , "dispo.bin" , "application/octet-stream" , & bytes)
912+ . add_text ( "file_name" , & explicit)
913+ . build ( ) ;
914+
915+ let req = upload_request ( & content_type, body, & token, & csrf) ;
916+ let resp = app. oneshot ( req) . await . unwrap ( ) ;
917+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
918+
919+ let json = body_json ( resp) . await ;
920+ let path = json[ "data" ] . as_str ( ) . unwrap ( ) ;
921+ assert ! ( path. ends_with( & explicit) , "expected filename {explicit}, got {path}" ) ;
922+ let _ = std:: fs:: remove_file ( path) ;
923+ }
924+
925+ #[ tokio:: test]
926+ async fn upload_missing_file_field_returns_400 ( ) {
927+ let ( mut app, services) = build_app ( ) . await ;
928+ let ( token, csrf) = setup_and_login ( & mut app, & services, "admin" , "StrongP@ss1" ) . await ;
929+
930+ let ( content_type, body) = UploadMultipart :: new ( ) . add_text ( "file_name" , "ignored.png" ) . build ( ) ;
931+
932+ let req = upload_request ( & content_type, body, & token, & csrf) ;
933+ let resp = app. oneshot ( req) . await . unwrap ( ) ;
934+ assert_eq ! ( resp. status( ) , StatusCode :: BAD_REQUEST ) ;
935+ let json = body_json ( resp) . await ;
936+ assert_eq ! ( json[ "success" ] , false ) ;
937+ }
938+
939+ #[ tokio:: test]
940+ async fn upload_body_exceeding_30mb_returns_413 ( ) {
941+ let ( mut app, services) = build_app ( ) . await ;
942+ let ( token, csrf) = setup_and_login ( & mut app, & services, "admin" , "StrongP@ss1" ) . await ;
943+
944+ // 31 MB payload comfortably exceeds UPLOAD_MAX_SIZE (30 MB).
945+ let big = vec ! [ 0u8 ; 31 * 1024 * 1024 ] ;
946+ let ( content_type, body) = UploadMultipart :: new ( )
947+ . add_file ( "file" , "big.bin" , "application/octet-stream" , & big)
948+ . build ( ) ;
949+
950+ let req = upload_request ( & content_type, body, & token, & csrf) ;
951+ let resp = app. oneshot ( req) . await . unwrap ( ) ;
952+ assert_eq ! ( resp. status( ) , StatusCode :: PAYLOAD_TOO_LARGE ) ;
953+ }
0 commit comments