Skip to content

Commit 45d52cc

Browse files
committed
add content-length to streamed file downloads for browsers / progress
1 parent c0e43eb commit 45d52cc

7 files changed

Lines changed: 94 additions & 24 deletions

File tree

crates/bin/docs_rs_web/src/file.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use axum::{
1010
};
1111
use axum_extra::{
1212
TypedHeader,
13-
headers::{ContentType, LastModified},
13+
headers::{ContentLength, ContentType, LastModified},
1414
};
1515
use docs_rs_headers::IfNoneMatch;
1616
use docs_rs_storage::{AsyncStorage, Blob, StreamingBlob};
@@ -81,7 +81,17 @@ impl StreamingFile {
8181
// Future optimization could be:
8282
// * only forbid fastly to store, and browsers still could.
8383
// * implement segmented caching for large files somehow.
84-
if self.0.content_length > FASTLY_CACHE_MAX_OBJECT_SIZE
84+
//
85+
// With this logic this error is only raised when we return the raw stream to the user,
86+
// so for zip-archives & rustdoc-json files, which are also the cases where we did hit these
87+
// fastly limits.
88+
// Streams we decompress (rustdoc content, assets) would lead to errors in fastly if the
89+
// decompressed file is >100 MiB.
90+
if self
91+
.0
92+
.content_length
93+
.map(|cl| cl > FASTLY_CACHE_MAX_OBJECT_SIZE)
94+
.unwrap_or(false)
8595
&& !matches!(cache_policy, CachePolicy::NoStoreMustRevalidate)
8696
{
8797
warn!(
@@ -113,6 +123,9 @@ impl StreamingFile {
113123
(
114124
StatusCode::OK,
115125
TypedHeader(ContentType::from(self.0.mime)),
126+
self.0
127+
.content_length
128+
.map(|cl| TypedHeader(ContentLength(cl as u64))),
116129
TypedHeader(last_modified),
117130
self.0.etag.map(TypedHeader),
118131
Extension(cache_policy),
@@ -132,7 +145,7 @@ mod tests {
132145
use docs_rs_headers::compute_etag;
133146
use docs_rs_storage::StorageKind;
134147
use docs_rs_types::CompressionAlgorithm;
135-
use http::header::{CACHE_CONTROL, ETAG, LAST_MODIFIED};
148+
use http::header::{CACHE_CONTROL, CONTENT_LENGTH, ETAG, LAST_MODIFIED};
136149
use std::{io, rc::Rc};
137150

138151
const CONTENT: &[u8] = b"Hello, world!";
@@ -148,15 +161,15 @@ mod tests {
148161
date_updated: Utc::now(),
149162
compression: alg,
150163
etag: Some(compute_etag(&content)),
151-
content_length: content.len(),
164+
content_length: Some(content.len()),
152165
content: Box::new(io::Cursor::new(content)),
153166
}
154167
}
155168

156169
#[test]
157170
fn test_big_file_stream_drops_cache_policy() {
158171
let mut stream = streaming_blob(CONTENT, None);
159-
stream.content_length = FASTLY_CACHE_MAX_OBJECT_SIZE + 1;
172+
stream.content_length = Some(FASTLY_CACHE_MAX_OBJECT_SIZE + 1);
160173

161174
let response =
162175
StreamingFile(stream).into_response(None, CachePolicy::ForeverInCdnAndBrowser);
@@ -177,6 +190,14 @@ mod tests {
177190
let resp = stream.into_response(None, STATIC_ASSET_CACHE_POLICY);
178191
assert!(resp.status().is_success());
179192
assert!(resp.headers().get(CACHE_CONTROL).is_none());
193+
assert_eq!(
194+
resp.headers()
195+
.get(CONTENT_LENGTH)
196+
.unwrap()
197+
.to_str()
198+
.unwrap(),
199+
CONTENT.len().to_string()
200+
);
180201
let cache = resp
181202
.extensions()
182203
.get::<CachePolicy>()
@@ -209,6 +230,26 @@ mod tests {
209230
Ok(())
210231
}
211232

233+
#[tokio::test]
234+
async fn test_stream_into_response_without_content_length() -> Result<()> {
235+
let mut stream = streaming_blob(CONTENT, None);
236+
stream.content_length = None;
237+
238+
let stream_file = StreamingFile(stream);
239+
let resp = stream_file.into_response(None, STATIC_ASSET_CACHE_POLICY);
240+
assert!(resp.status().is_success());
241+
assert!(resp.headers().get(CACHE_CONTROL).is_none());
242+
assert!(resp.headers().get(CONTENT_LENGTH).is_none());
243+
let cache = resp
244+
.extensions()
245+
.get::<CachePolicy>()
246+
.expect("missing cache response extension");
247+
assert!(matches!(cache, CachePolicy::ForeverInCdnAndBrowser));
248+
assert!(resp.headers().get(LAST_MODIFIED).is_some());
249+
250+
Ok(())
251+
}
252+
212253
#[tokio::test(flavor = "multi_thread")]
213254
async fn file_roundtrip_axum() -> Result<()> {
214255
let env = TestEnvironment::new().await?;

crates/bin/docs_rs_web/src/handlers/rustdoc.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,13 +1104,26 @@ mod test {
11041104
testing::{KRATE, V2},
11051105
};
11061106
use docs_rs_uri::encode_url_path;
1107+
use http::header::CONTENT_LENGTH;
11071108
use kuchikiki::traits::TendrilSink;
11081109
use pretty_assertions::assert_eq;
11091110
use reqwest::StatusCode;
11101111
use std::{collections::BTreeMap, str::FromStr as _};
11111112
use test_case::test_case;
11121113
use tracing::info;
11131114

1115+
fn has_content_len(headers: &HeaderMap) -> bool {
1116+
let content_length: usize = headers
1117+
.get(CONTENT_LENGTH)
1118+
.unwrap()
1119+
.to_str()
1120+
.unwrap()
1121+
.parse()
1122+
.unwrap();
1123+
1124+
content_length > 0
1125+
}
1126+
11141127
async fn try_latest_version_redirect(
11151128
krate: &str,
11161129
path: &str,
@@ -3229,6 +3242,7 @@ mod test {
32293242
resp.headers().get(CONTENT_DISPOSITION).unwrap(),
32303243
"attachment; filename=\"rustdoc-dummy-0.2.0.zip\""
32313244
);
3245+
assert!(has_content_len(resp.headers()));
32323246
web.assert_conditional_get(path, &resp).await?;
32333247

32343248
check_archive_consistency(&web.assert_success(path).await?.bytes().await?)?;
@@ -3627,6 +3641,9 @@ mod test {
36273641
expected_compression.file_extension()
36283642
)
36293643
);
3644+
3645+
assert!(has_content_len(resp.headers()));
3646+
36303647
web.assert_conditional_get(&path, &resp).await?;
36313648

36323649
{

crates/bin/docs_rs_web/src/testing/axum_helpers.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use axum::body::Bytes;
44
use axum::{body::Body, http::Request, response::Response as AxumResponse};
55
use axum_extra::headers::{ETag, HeaderMapExt as _};
66
use docs_rs_headers::{IfNoneMatch, SURROGATE_CONTROL, SurrogateKeys};
7+
use http::header::CONTENT_LENGTH;
78
use http::{
89
HeaderMap, HeaderName, HeaderValue, StatusCode,
910
header::{CACHE_CONTROL, CONTENT_TYPE},
@@ -166,7 +167,7 @@ impl AxumRouterTestExt for axum::Router {
166167
// it should be repeated on the 304 response.
167168
//
168169
// This logic assumes _all_ headers have to be repeated, except for a few known ones.
169-
const NON_CACHE_HEADERS: &[&HeaderName] = &[&CONTENT_TYPE];
170+
const NON_CACHE_HEADERS: &[&HeaderName] = &[&CONTENT_TYPE, &CONTENT_LENGTH];
170171

171172
// store original headers, to assert that they are repeated on the 304 response.
172173
let original_headers: HashMap<HeaderName, HeaderValue> = uncached_response

crates/lib/docs_rs_storage/src/archive_index.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,7 @@ mod tests {
11001100
date_updated: Utc::now(),
11011101
etag: None,
11021102
compression: None,
1103-
content_length: content.len(),
1103+
content_length: Some(content.len()),
11041104
content: Box::new(Cursor::new(content)),
11051105
})
11061106
})
@@ -1155,7 +1155,7 @@ mod tests {
11551155
date_updated: Utc::now(),
11561156
etag: None,
11571157
compression: None,
1158-
content_length: content.len(),
1158+
content_length: Some(content.len()),
11591159
content: Box::new(Cursor::new(content)),
11601160
})
11611161
})

crates/lib/docs_rs_storage/src/backends/s3.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,7 @@ impl StorageBackendMethods for S3Backend {
461461
.unwrap_or(mime::APPLICATION_OCTET_STREAM),
462462
date_updated,
463463
etag,
464-
content_length: res
465-
.content_length
466-
.and_then(|length| length.try_into().ok())
467-
.unwrap_or(0),
464+
content_length: res.content_length.and_then(|length| length.try_into().ok()),
468465
content: Box::new(res.body.into_async_read()),
469466
compression,
470467
})

crates/lib/docs_rs_storage/src/blob.rs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ pub struct StreamingBlob {
8888
pub date_updated: DateTime<Utc>,
8989
pub etag: Option<ETag>,
9090
pub compression: Option<CompressionAlgorithm>,
91-
pub content_length: usize,
91+
/// initially the original content length on S3,
92+
/// what we return to the user might be bigger because of
93+
/// streaming decompression.
94+
/// Will be emptied when we decompress the blob because then
95+
/// re return more data than we have on S3.
96+
pub content_length: Option<usize>,
9297
pub content: Box<dyn AsyncBufRead + Unpin + Send>,
9398
}
9499

@@ -100,6 +105,7 @@ impl fmt::Debug for StreamingBlob {
100105
.field("date_updated", &self.date_updated)
101106
.field("etag", &self.etag)
102107
.field("compression", &self.compression)
108+
.field("content_length", &self.content_length)
103109
.finish()
104110
}
105111
}
@@ -133,14 +139,15 @@ impl StreamingBlob {
133139
);
134140

135141
self.compression = None;
142+
self.content_length = None;
136143
// not touching the etag, it should represent the original content
137144
Ok(self)
138145
}
139146

140147
/// consume the inner stream and materialize the full blob into memory.
141148
pub async fn materialize(mut self, max_size: usize) -> Result<Blob> {
142149
let mut content = SizedBuffer::new(max_size);
143-
content.reserve(self.content_length);
150+
content.reserve(self.content_length.unwrap_or(16 * 1024));
144151

145152
io::copy(&mut self.content, &mut content).await?;
146153

@@ -163,7 +170,7 @@ impl From<Blob> for StreamingBlob {
163170
date_updated: value.date_updated,
164171
etag: value.etag,
165172
compression: value.compression,
166-
content_length: value.content.len(),
173+
content_length: Some(value.content.len()),
167174
content: Box::new(Cursor::new(value.content)),
168175
}
169176
}
@@ -188,7 +195,7 @@ mod test {
188195
date_updated: Utc::now(),
189196
compression: alg,
190197
etag: Some(compute_etag(&content)),
191-
content_length: content.len(),
198+
content_length: Some(content.len()),
192199
content: Box::new(Cursor::new(content)),
193200
}
194201
}
@@ -200,14 +207,17 @@ mod test {
200207
// without decompression
201208
{
202209
let stream = streaming_blob(CONTENT, None);
210+
assert_eq!(stream.content_length, Some(CONTENT.len()));
203211
let blob = stream.materialize(usize::MAX).await?;
204212
assert_eq!(blob.content, CONTENT);
205213
assert!(blob.compression.is_none());
206214
}
207215

208216
// with decompression, does nothing
209217
{
210-
let stream = streaming_blob(CONTENT, None);
218+
let stream = streaming_blob(CONTENT, None).decompress().await?;
219+
// no compression, content length stays valid
220+
assert_eq!(stream.content_length, Some(CONTENT.len()));
211221
let blob = stream.decompress().await?.materialize(usize::MAX).await?;
212222
assert_eq!(blob.content, CONTENT);
213223
assert!(blob.compression.is_none());
@@ -225,6 +235,7 @@ mod test {
225235
// Doesn't fail because we don't call `.decompress`
226236
{
227237
let stream = streaming_blob(NOT_ZSTD, Some(alg));
238+
assert_eq!(stream.content_length, Some(NOT_ZSTD.len()));
228239
let blob = stream.materialize(usize::MAX).await?;
229240
assert_eq!(blob.content, NOT_ZSTD);
230241
assert_eq!(blob.compression, Some(alg));
@@ -267,6 +278,7 @@ mod test {
267278
// without decompression
268279
{
269280
let stream = streaming_blob(compressed_content.clone(), Some(alg));
281+
assert_eq!(stream.content_length, Some(compressed_content.len()));
270282
let blob = stream.materialize(usize::MAX).await?;
271283
assert_eq!(blob.content, compressed_content);
272284
assert_eq!(blob.content.last_chunk::<3>().unwrap(), &ZSTD_EOF_BYTES);
@@ -275,11 +287,12 @@ mod test {
275287

276288
// with decompression
277289
{
278-
let blob = streaming_blob(compressed_content.clone(), Some(alg))
290+
let stream = streaming_blob(compressed_content.clone(), Some(alg))
279291
.decompress()
280-
.await?
281-
.materialize(usize::MAX)
282292
.await?;
293+
// content length becomes unknown with decompression
294+
assert!(stream.content_length.is_none());
295+
let blob = stream.materialize(usize::MAX).await?;
283296
assert_eq!(blob.content, CONTENT);
284297
assert!(blob.compression.is_none());
285298
}

crates/lib/docs_rs_storage/src/compression.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,19 +219,20 @@ mod tests {
219219
}
220220

221221
// try decompress via storage API
222-
let blob = StreamingBlob {
222+
let stream = StreamingBlob {
223223
path: "some_path.db".into(),
224224
mime: mime::APPLICATION_OCTET_STREAM,
225225
date_updated: Utc::now(),
226226
etag: None,
227227
compression: Some(alg),
228-
content_length: compressed_index_content.len(),
228+
content_length: Some(compressed_index_content.len()),
229229
content: Box::new(io::Cursor::new(compressed_index_content)),
230230
}
231231
.decompress()
232-
.await?
233-
.materialize(usize::MAX)
234232
.await?;
233+
assert!(stream.content_length.is_none());
234+
235+
let blob = stream.materialize(usize::MAX).await?;
235236

236237
assert_eq!(blob.compression, None);
237238
assert_eq!(blob.content, CONTENT);

0 commit comments

Comments
 (0)