pharos_runtime_static/
browser_cache.rs1use pharos_runtime_core::http::HttpRequest;
4
5pub fn deployment_headers(
8 request: &HttpRequest,
9 response_head: &str,
10 app_id: &str,
11 mount: &str,
12 identity: &str,
13 secure: bool,
14) -> Vec<(&'static str, String)> {
15 if request.method != "GET"
16 || !response_head.starts_with("HTTP/1.1 200 ")
17 || !response_head.lines().any(|line| {
18 line.split_once(':').is_some_and(|(name, value)| {
19 name.eq_ignore_ascii_case("content-type") && value.trim().starts_with("text/html")
20 })
21 })
22 || request.headers.lines().any(|line| {
23 line.split_once(':').is_some_and(|(name, value)| {
24 name.eq_ignore_ascii_case("hx-request") && value.trim().eq_ignore_ascii_case("true")
25 })
26 })
27 || app_id.is_empty()
28 || app_id.len() > 64
29 || !app_id
30 .bytes()
31 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
32 || !mount.starts_with('/')
33 || mount.len() > 1024
34 || !mount
35 .bytes()
36 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.'))
37 || identity.len() != 64
38 || !identity.bytes().all(|byte| byte.is_ascii_hexdigit())
39 {
40 return Vec::new();
41 }
42 let name = format!("pharos_build_{app_id}");
43 if request
44 .cookie
45 .as_deref()
46 .unwrap_or_default()
47 .split(';')
48 .any(|part| {
49 part.trim()
50 .split_once('=')
51 .is_some_and(|(key, value)| key == name && value == identity)
52 })
53 {
54 return Vec::new();
55 }
56 vec![
57 ("Clear-Site-Data", "\"cache\"".to_owned()),
58 (
59 "Set-Cookie",
60 format!(
61 "{name}={identity}; Path={mount}; Max-Age=31536000; HttpOnly; SameSite=Lax{}",
62 if secure { "; Secure" } else { "" }
63 ),
64 ),
65 ]
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
74 fn clears_only_http_cache_once_per_build() {
75 let identity = "a".repeat(64);
76 let head = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8";
77 let mut request = HttpRequest {
78 method: "GET".to_owned(),
79 ..Default::default()
80 };
81 let headers = deployment_headers(
82 &request,
83 head,
84 "commerce_spa",
85 "/beacon-beats",
86 &identity,
87 true,
88 );
89 assert_eq!(headers[0], ("Clear-Site-Data", "\"cache\"".to_owned()));
90 assert!(headers[1].1.ends_with("; Secure"));
91 assert!(!headers[1].1.contains("pharos_session"));
92 request.cookie = Some(format!(
93 "pharos_session=retained; pharos_build_commerce_spa={identity}"
94 ));
95 assert!(
96 deployment_headers(
97 &request,
98 head,
99 "commerce_spa",
100 "/beacon-beats",
101 &identity,
102 true
103 )
104 .is_empty()
105 );
106 assert!(
107 !deployment_headers(
108 &request,
109 head,
110 "commerce_spa",
111 "/beacon-beats",
112 &"b".repeat(64),
113 true
114 )
115 .is_empty()
116 );
117 request.headers = "HX-Request: true\r\n".to_owned();
118 assert!(
119 deployment_headers(
120 &request,
121 head,
122 "commerce_spa",
123 "/beacon-beats",
124 &identity,
125 true
126 )
127 .is_empty()
128 );
129 request.headers.clear();
130 request.cookie = None;
131 request.method = "POST".to_owned();
132 assert!(
133 deployment_headers(
134 &request,
135 head,
136 "commerce_spa",
137 "/beacon-beats",
138 &identity,
139 true
140 )
141 .is_empty()
142 );
143 request.method = "GET".to_owned();
144 assert!(
145 deployment_headers(
146 &request,
147 "HTTP/1.1 200 OK\r\nContent-Type: text/css",
148 "commerce_spa",
149 "/beacon-beats",
150 &identity,
151 true
152 )
153 .is_empty()
154 );
155 assert!(
156 deployment_headers(&request, head, "commerce_spa", "/bad;path", &identity, true)
157 .is_empty()
158 );
159 }
160}