Skip to content

Commit 98595bc

Browse files
committed
add missing content-disposition header to rustdoc-json downloads
1 parent 1b9214b commit 98595bc

File tree

1 file changed

+129
-17
lines changed

1 file changed

+129
-17
lines changed

src/web/rustdoc.rs

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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};
4545
use serde::Deserialize;
4646
use std::{
4747
collections::HashMap,
4848
sync::{Arc, LazyLock},
4949
};
5050
use 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)]
5368
pub(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

Comments
 (0)