@@ -41,14 +41,29 @@ use axum_extra::{
4141 headers:: { ContentType , ETag , Header as _, HeaderMapExt as _} ,
4242 typed_header:: TypedHeader ,
4343} ;
44- use http:: { HeaderMap , Uri , uri:: Authority } ;
44+ use http:: { HeaderMap , HeaderValue , Uri , header :: CONTENT_DISPOSITION , uri:: Authority } ;
4545use serde:: Deserialize ;
4646use std:: {
4747 collections:: HashMap ,
4848 sync:: { Arc , LazyLock } ,
4949} ;
5050use tracing:: { Instrument , error, info_span, instrument, trace} ;
5151
52+ /// generate a "attachment" content disposition header for downloads.
53+ ///
54+ /// Used in archive-download & json-download endpoints.
55+ ///
56+ /// Typically I like typed-headers more, but the `headers::ContentDisposition` impl is lacking,
57+ /// and I don't want to rebuild it now.
58+ fn generate_content_disposition_header ( storage_path : & str ) -> anyhow:: Result < HeaderValue > {
59+ format ! (
60+ "attachment; filename=\" {}\" " ,
61+ storage_path. replace( "/" , "-" )
62+ )
63+ . parse ( )
64+ . map_err ( Into :: into)
65+ }
66+
5267#[ derive( Debug , Clone , PartialEq , Eq ) ]
5368pub ( crate ) struct OfficialCrateDescription {
5469 pub ( crate ) name : & ' static str ,
@@ -961,8 +976,11 @@ pub(crate) async fn json_download_handler(
961976 Some ( wanted_compression) ,
962977 ) ;
963978
964- let mut response = match storage. get_raw_stream ( & storage_path) . await {
965- Ok ( file) => StreamingFile ( file) . into_response ( if_none_match. as_deref ( ) ) ,
979+ let ( mut response, updated_storage_path) = match storage. get_raw_stream ( & storage_path) . await {
980+ Ok ( file) => (
981+ StreamingFile ( file) . into_response ( if_none_match. as_deref ( ) ) ,
982+ None ,
983+ ) ,
966984 Err ( err) if matches ! ( err. downcast_ref( ) , Some ( crate :: storage:: PathNotFoundError ) ) => {
967985 // we have old files on the bucket where we stored zstd compressed files,
968986 // with content-encoding=zstd & just a `.json` file extension.
@@ -977,8 +995,11 @@ pub(crate) async fn json_download_handler(
977995 ) ;
978996 // we have an old file with a `.json` extension,
979997 // redirect to that as fallback
980- StreamingFile ( storage. get_raw_stream ( & storage_path) . await ?)
981- . into_response ( if_none_match. as_deref ( ) )
998+ (
999+ StreamingFile ( storage. get_raw_stream ( & storage_path) . await ?)
1000+ . into_response ( if_none_match. as_deref ( ) ) ,
1001+ Some ( storage_path) ,
1002+ )
9821003 } else {
9831004 return Err ( AxumNope :: ResourceNotFound ) ;
9841005 }
@@ -991,6 +1012,17 @@ pub(crate) async fn json_download_handler(
9911012 // Here we override it with the standard policy for build output.
9921013 response. extensions_mut ( ) . insert ( CachePolicy :: ForeverInCdn ) ;
9931014
1015+ // set content-disposition to attachment to trigger download in browsers
1016+ // For the attachment filename we can use just the filename without the path,
1017+ // since that already contains all the info.
1018+ let storage_path = updated_storage_path. unwrap_or ( storage_path) ;
1019+ let ( _, filename) = storage_path. rsplit_once ( '/' ) . unwrap_or ( ( "" , & storage_path) ) ;
1020+ response. headers_mut ( ) . insert (
1021+ CONTENT_DISPOSITION ,
1022+ generate_content_disposition_header ( filename)
1023+ . context ( "could not generate content-disposition header" ) ?,
1024+ ) ;
1025+
9941026 Ok ( response)
9951027}
9961028
@@ -3354,21 +3386,90 @@ mod test {
33543386 Ok ( ( ) )
33553387 }
33563388
3357- #[ test_case( "latest/json" , CompressionAlgorithm :: Zstd ) ]
3358- #[ test_case( "latest/json.gz" , CompressionAlgorithm :: Gzip ) ]
3359- #[ test_case( "0.1.0/json" , CompressionAlgorithm :: Zstd ) ]
3360- #[ test_case( "latest/json/latest" , CompressionAlgorithm :: Zstd ) ]
3361- #[ test_case( "latest/json/latest.gz" , CompressionAlgorithm :: Gzip ) ]
3362- #[ test_case( "latest/json/42" , CompressionAlgorithm :: Zstd ) ]
3363- #[ test_case( "latest/i686-pc-windows-msvc/json" , CompressionAlgorithm :: Zstd ) ]
3364- #[ test_case( "latest/i686-pc-windows-msvc/json.gz" , CompressionAlgorithm :: Gzip ) ]
3365- #[ test_case( "latest/i686-pc-windows-msvc/json/42" , CompressionAlgorithm :: Zstd ) ]
3366- #[ test_case( "latest/i686-pc-windows-msvc/json/42.gz" , CompressionAlgorithm :: Gzip ) ]
3367- #[ test_case( "latest/i686-pc-windows-msvc/json/42.zst" , CompressionAlgorithm :: Zstd ) ]
3389+ #[ test_case(
3390+ "latest/json" ,
3391+ CompressionAlgorithm :: Zstd ,
3392+ "x86_64-unknown-linux-gnu" ,
3393+ "latest" ,
3394+ "0.2.0"
3395+ ) ]
3396+ #[ test_case(
3397+ "latest/json.gz" ,
3398+ CompressionAlgorithm :: Gzip ,
3399+ "x86_64-unknown-linux-gnu" ,
3400+ "latest" ,
3401+ "0.2.0"
3402+ ) ]
3403+ #[ test_case(
3404+ "0.1.0/json" ,
3405+ CompressionAlgorithm :: Zstd ,
3406+ "x86_64-unknown-linux-gnu" ,
3407+ "latest" ,
3408+ "0.1.0"
3409+ ) ]
3410+ #[ test_case(
3411+ "latest/json/latest" ,
3412+ CompressionAlgorithm :: Zstd ,
3413+ "x86_64-unknown-linux-gnu" ,
3414+ "latest" ,
3415+ "0.2.0"
3416+ ) ]
3417+ #[ test_case(
3418+ "latest/json/latest.gz" ,
3419+ CompressionAlgorithm :: Gzip ,
3420+ "x86_64-unknown-linux-gnu" ,
3421+ "latest" ,
3422+ "0.2.0"
3423+ ) ]
3424+ #[ test_case(
3425+ "latest/json/42" ,
3426+ CompressionAlgorithm :: Zstd ,
3427+ "x86_64-unknown-linux-gnu" ,
3428+ "42" ,
3429+ "0.2.0"
3430+ ) ]
3431+ #[ test_case(
3432+ "latest/i686-pc-windows-msvc/json" ,
3433+ CompressionAlgorithm :: Zstd ,
3434+ "i686-pc-windows-msvc" ,
3435+ "latest" ,
3436+ "0.2.0"
3437+ ) ]
3438+ #[ test_case(
3439+ "latest/i686-pc-windows-msvc/json.gz" ,
3440+ CompressionAlgorithm :: Gzip ,
3441+ "i686-pc-windows-msvc" ,
3442+ "latest" ,
3443+ "0.2.0"
3444+ ) ]
3445+ #[ test_case(
3446+ "latest/i686-pc-windows-msvc/json/42" ,
3447+ CompressionAlgorithm :: Zstd ,
3448+ "i686-pc-windows-msvc" ,
3449+ "42" ,
3450+ "0.2.0"
3451+ ) ]
3452+ #[ test_case(
3453+ "latest/i686-pc-windows-msvc/json/42.gz" ,
3454+ CompressionAlgorithm :: Gzip ,
3455+ "i686-pc-windows-msvc" ,
3456+ "42" ,
3457+ "0.2.0"
3458+ ) ]
3459+ #[ test_case(
3460+ "latest/i686-pc-windows-msvc/json/42.zst" ,
3461+ CompressionAlgorithm :: Zstd ,
3462+ "i686-pc-windows-msvc" ,
3463+ "42" ,
3464+ "0.2.0"
3465+ ) ]
33683466 #[ tokio:: test( flavor = "multi_thread" ) ]
33693467 async fn json_download (
33703468 request_path_suffix : & str ,
33713469 expected_compression : CompressionAlgorithm ,
3470+ expected_target : & str ,
3471+ expected_format_version : & str ,
3472+ expected_version : & str ,
33723473 ) -> Result < ( ) > {
33733474 let env = TestEnvironment :: new ( ) . await ?;
33743475
@@ -3398,6 +3499,13 @@ mod test {
33983499 let resp = web
33993500 . assert_success_cached ( & path, CachePolicy :: ForeverInCdn , env. config ( ) )
34003501 . await ?;
3502+ assert_eq ! (
3503+ resp. headers( ) . get( CONTENT_DISPOSITION ) . unwrap( ) ,
3504+ & format!(
3505+ "attachment; filename=\" dummy_{expected_version}_{expected_target}_{expected_format_version}.json.{}\" " ,
3506+ expected_compression. file_extension( )
3507+ )
3508+ ) ;
34013509 web. assert_conditional_get ( & path, & resp) . await ?;
34023510
34033511 {
@@ -3470,6 +3578,10 @@ mod test {
34703578 let resp = web
34713579 . assert_success_cached ( & path, CachePolicy :: ForeverInCdn , env. config ( ) )
34723580 . await ?;
3581+ assert_eq ! (
3582+ resp. headers( ) . get( CONTENT_DISPOSITION ) . unwrap( ) ,
3583+ & format!( "attachment; filename=\" {NAME}_{VERSION}_{TARGET}_latest.json\" " ) ,
3584+ ) ;
34733585 web. assert_conditional_get ( & path, & resp) . await ?;
34743586 Ok ( ( ) )
34753587 }
@@ -3508,7 +3620,7 @@ mod test {
35083620 let response = web
35093621 . get ( & format ! ( "/crate/dummy/{request_path_suffix}" ) )
35103622 . await ?;
3511-
3623+ assert ! ( response . headers ( ) . get ( CONTENT_DISPOSITION ) . is_none ( ) ) ;
35123624 assert_eq ! ( response. status( ) , StatusCode :: NOT_FOUND ) ;
35133625 Ok ( ( ) )
35143626 }
0 commit comments