whiskers.git / web / worker / src / media.rs
media.rsannotatedmedia.rssource190 lines · 8.8 KB · raw
1//! The link preview's video, answered with byte ranges.
2//!
3//! Discord, Twitter and iOS fetch a video in pieces (`Range: bytes=...`) and may refuse one that
4//! cannot be. Workers static assets answer a range with the whole file, so `/preview/loop.mp4`
5//! is routed to this code first (`run_worker_first` in `wrangler.toml`); the file is read through
6//! the `ASSETS` binding and sliced here. Which bytes is `site::range`'s decision; this file only
7//! sets headers. The PNG stays a plain asset.
8
9use axum::http::{Method, Response, StatusCode, header};
10use site::range::{Selection, select};
11
12/// The route, and the binding it is read through (`[assets] binding` in `wrangler.toml`).
13pub const PATH: &str = "/preview/loop.mp4";
14pub const BINDING: &str = "ASSETS";
15
16/// Same as the other assets: the address does not name the content, so a copy is kept only
17/// while its `ETag` still matches. `no-transform` for the reason `headers::HTML_CACHE` has it.
18const CACHE: &str = "public, max-age=0, must-revalidate, no-transform";
19
20/// The request headers that matter here, as text.
21#[derive(Default)]
22pub struct Asked<'a> {
23    pub range: Option<&'a str>,
24    pub if_range: Option<&'a str>,
25    pub if_none_match: Option<&'a str>,
26}
27
28/// A name for the file's content (FNV-1a, 64 bit): not a secret, only a validator.
29pub fn etag(bytes: &[u8]) -> String {
30    let hash = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
31    format!("\"{hash:016x}\"")
32}
33
34/// Does an `If-None-Match` / `If-Range` validator name this `ETag`? Weak comparison, as
35/// RFC 9110 uses for `If-None-Match`; `If-Range` is stricter but a weak tag never matches ours.
36fn names(list: &str, etag: &str) -> bool {
37    list.split(',').map(str::trim).any(|candidate| candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag)
38}
39
40/// The answer for `bytes`, the whole file. The body is plain bytes so the tests can read it;
41/// the Worker wraps it.
42pub fn respond(method: &Method, asked: &Asked, bytes: Vec<u8>) -> Response<Vec<u8>> {
43    let len = bytes.len() as u64;
44    let tag = etag(&bytes);
45    let base = || Response::builder().header(header::ACCEPT_RANGES, "bytes").header(header::ETAG, &tag).header(header::CACHE_CONTROL, CACHE);
46
47    if asked.if_none_match.is_some_and(|list| names(list, &tag)) {
48        return base().status(StatusCode::NOT_MODIFIED).body(Vec::new()).expect("static headers are valid");
49    }
50
51    // `If-Range`: the range applies only if the copy the client holds is this one.
52    let range = if asked.if_range.is_some_and(|validator| validator.trim() != tag) { None } else { asked.range };
53    let (status, start, end) = match select(range, len) {
54        Selection::Whole => (StatusCode::OK, 0, len),
55        Selection::Partial { start, end } => (StatusCode::PARTIAL_CONTENT, start, end + 1),
56        Selection::Unsatisfiable => {
57            return base()
58                .status(StatusCode::RANGE_NOT_SATISFIABLE)
59                .header(header::CONTENT_RANGE, format!("bytes */{len}"))
60                .header(header::CONTENT_LENGTH, 0)
61                .body(Vec::new())
62                .expect("static headers are valid");
63        }
64    };
65
66    let mut builder = base().status(status).header(header::CONTENT_TYPE, "video/mp4").header(header::CONTENT_LENGTH, end - start);
67    if status == StatusCode::PARTIAL_CONTENT {
68        builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{}/{len}", end - 1));
69    }
70    let body = if method == Method::HEAD { Vec::new() } else { bytes[start as usize..end as usize].to_vec() };
71    builder.body(body).expect("static headers are valid")
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn file() -> Vec<u8> {
79        (0..=255u8).cycle().take(1000).collect()
80    }
81
82    fn body(response: Response<Vec<u8>>) -> Vec<u8> {
83        response.into_body()
84    }
85
86    fn header_of<'a>(response: &'a Response<Vec<u8>>, name: header::HeaderName) -> &'a str {
87        response.headers()[name].to_str().unwrap()
88    }
89
90    fn ranged(range: &str) -> Asked<'_> {
91        Asked { range: Some(range), ..Asked::default() }
92    }
93
94    #[test]
95    fn no_range_is_the_whole_file_that_says_it_takes_ranges() {
96        let response = respond(&Method::GET, &Asked::default(), file());
97        assert_eq!(response.status(), 200);
98        assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
99        assert_eq!(header_of(&response, header::CONTENT_TYPE), "video/mp4");
100        assert_eq!(header_of(&response, header::CONTENT_LENGTH), "1000");
101        assert!(response.headers().get(header::CONTENT_RANGE).is_none());
102        assert_eq!(body(response), file());
103    }
104
105    #[test]
106    fn a_range_is_a_206_with_exactly_those_bytes() {
107        for (asked, from, to) in [("bytes=0-99", 0, 99), ("bytes=0-", 0, 999), ("bytes=900-", 900, 999), ("bytes=-10", 990, 999), ("bytes=5-5000", 5, 999)] {
108            let response = respond(&Method::GET, &ranged(asked), file());
109            assert_eq!(response.status(), 206, "{asked}");
110            assert_eq!(header_of(&response, header::CONTENT_RANGE), format!("bytes {from}-{to}/1000"));
111            assert_eq!(header_of(&response, header::CONTENT_LENGTH), (to - from + 1).to_string());
112            assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
113            assert_eq!(header_of(&response, header::CONTENT_TYPE), "video/mp4");
114            assert_eq!(body(response), file()[from..=to], "{asked}");
115        }
116    }
117
118    #[test]
119    fn a_range_past_the_end_is_a_416_that_says_how_long_the_file_is() {
120        let response = respond(&Method::GET, &ranged("bytes=1000-"), file());
121        assert_eq!(response.status(), 416);
122        assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes */1000");
123        assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
124        assert!(body(response).is_empty());
125    }
126
127    #[test]
128    fn a_range_that_is_not_understood_is_the_whole_file() {
129        for asked in ["bytes=0-1,5-6", "bytes=9-2", "items=0-5", "garbage", "bytes=-"] {
130            let response = respond(&Method::GET, &ranged(asked), file());
131            assert_eq!(response.status(), 200, "{asked}");
132            assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
133            assert_eq!(body(response).len(), 1000);
134        }
135    }
136
137    #[test]
138    fn head_has_the_headers_of_a_get_and_no_body() {
139        let get = respond(&Method::GET, &ranged("bytes=10-19"), file());
140        let head = respond(&Method::HEAD, &ranged("bytes=10-19"), file());
141        assert_eq!(head.status(), 206);
142        for name in [header::CONTENT_LENGTH, header::CONTENT_RANGE, header::CONTENT_TYPE, header::ETAG, header::ACCEPT_RANGES, header::CACHE_CONTROL] {
143            assert_eq!(head.headers()[&name], get.headers()[&name], "{name}");
144        }
145        assert_eq!(header_of(&head, header::CONTENT_LENGTH), "10");
146        assert!(body(head).is_empty());
147
148        let whole = respond(&Method::HEAD, &Asked::default(), file());
149        assert_eq!(whole.status(), 200);
150        assert_eq!(header_of(&whole, header::CONTENT_LENGTH), "1000");
151    }
152
153    #[test]
154    fn an_empty_file_is_served_and_satisfies_no_range() {
155        assert_eq!(respond(&Method::GET, &Asked::default(), vec![]).status(), 200);
156        let response = respond(&Method::GET, &ranged("bytes=0-"), vec![]);
157        assert_eq!(response.status(), 416);
158        assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes */0");
159    }
160
161    #[test]
162    fn the_validator_follows_the_content_and_keeps_the_cache_honest() {
163        assert_eq!(etag(&file()), etag(&file()));
164        assert_ne!(etag(&file()), etag(&file()[1..]));
165        let response = respond(&Method::GET, &Asked::default(), file());
166        assert_eq!(header_of(&response, header::ETAG), etag(&file()));
167        let cache = header_of(&response, header::CACHE_CONTROL);
168        assert!(cache.contains("no-transform") && cache.contains("must-revalidate"));
169    }
170
171    #[test]
172    fn an_unchanged_copy_is_a_304() {
173        let tag = etag(&file());
174        for list in [tag.clone(), format!("W/{tag}"), format!("\"other\", {tag}"), "*".to_owned()] {
175            let asked = Asked { if_none_match: Some(&list), ..Asked::default() };
176            assert_eq!(respond(&Method::GET, &asked, file()).status(), 304, "{list}");
177        }
178        let asked = Asked { if_none_match: Some("\"other\""), ..Asked::default() };
179        assert_eq!(respond(&Method::GET, &asked, file()).status(), 200);
180    }
181
182    #[test]
183    fn if_range_applies_the_range_only_to_the_copy_it_names() {
184        let tag = etag(&file());
185        let same = Asked { range: Some("bytes=0-9"), if_range: Some(&tag), ..Asked::default() };
186        assert_eq!(respond(&Method::GET, &same, file()).status(), 206);
187        let stale = Asked { range: Some("bytes=0-9"), if_range: Some("\"old\""), ..Asked::default() };
188        assert_eq!(respond(&Method::GET, &stale, file()).status(), 200);
189    }
190}