1use std::collections::HashMap;
5use std::sync::{
6 Arc, Mutex, OnceLock, Weak,
7 atomic::{AtomicU64, Ordering},
8};
9
10#[derive(Clone, Debug)]
14pub struct ResourceRequest {
15 pub path: String,
16 pub query: Option<String>,
17 pub method: String,
18}
19#[derive(Clone, Debug)]
22pub struct ResourceResponse {
23 pub status: u16,
24 pub mime: String,
25 pub body: Arc<[u8]>,
26 pub headers: Vec<(String, String)>,
27}
28impl ResourceResponse {
29 pub fn new(mime: impl Into<String>, body: impl Into<Arc<[u8]>>) -> Self {
30 Self {
31 status: 200,
32 mime: mime.into(),
33 body: body.into(),
34 headers: Vec::new(),
35 }
36 }
37 pub fn not_found() -> Self {
38 Self::error(404)
39 }
40 pub fn error(status: u16) -> Self {
41 Self {
42 status,
43 ..Self::new("text/plain", Vec::new())
44 }
45 }
46}
47type Handler = dyn Fn(ResourceRequest) -> ResourceResponse + Send + Sync;
48struct Provider {
49 id: u64,
50 handler: Box<Handler>,
51}
52impl Drop for Provider {
53 fn drop(&mut self) {
54 registry().lock().unwrap().remove(&self.id);
55 }
56}
57#[derive(Clone)]
60pub struct ResourceProvider(Arc<Provider>);
61impl std::fmt::Debug for ResourceProvider {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_tuple("ResourceProvider").field(&self.0.id).finish()
64 }
65}
66impl PartialEq for ResourceProvider {
67 fn eq(&self, other: &Self) -> bool {
68 self.0.id == other.0.id
69 }
70}
71impl Eq for ResourceProvider {}
72fn registry() -> &'static Mutex<HashMap<u64, Weak<Provider>>> {
73 static REG: OnceLock<Mutex<HashMap<u64, Weak<Provider>>>> = OnceLock::new();
74 REG.get_or_init(Default::default)
75}
76impl ResourceProvider {
77 pub fn new(
78 handler: impl Fn(ResourceRequest) -> ResourceResponse + Send + Sync + 'static,
79 ) -> Self {
80 static NEXT: AtomicU64 = AtomicU64::new(1);
81 let p = Arc::new(Provider {
82 id: NEXT.fetch_add(1, Ordering::Relaxed),
83 handler: Box::new(handler),
84 });
85 registry().lock().unwrap().insert(p.id, Arc::downgrade(&p));
86 Self(p)
87 }
88 pub fn id(&self) -> u64 {
89 self.0.id
90 }
91 pub fn base_url(&self) -> String {
93 if cfg!(target_arch = "wasm32") {
94 format!(
95 "assets/data/day-piece-webview/{}/{}/",
96 browser_namespace(),
97 self.id()
98 )
99 } else if cfg!(all(feature = "qt", day_qt_webengine)) {
100 format!("day-resource://p{}/", self.id())
101 } else if cfg!(all(windows, not(feature = "xaml"))) {
102 format!("http://day-resource.p{}/", self.id())
103 } else if cfg!(any(target_os = "android", windows, target_env = "ohos")) {
104 format!("https://day-resource.invalid/{}/", self.id())
105 } else {
106 format!("day-resource://p{}/", self.id())
107 }
108 }
109 pub fn url(&self, path: &str) -> String {
112 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
113 format!(
114 "{}{}",
115 self.base_url(),
116 path.split('/')
117 .map(|p| utf8_percent_encode(p, NON_ALPHANUMERIC).to_string())
118 .collect::<Vec<_>>()
119 .join("/")
120 )
121 }
122}
123
124pub(crate) fn respond(id: u64, url: &str, method: &str, range: &str) -> ResourceResponse {
125 let p = registry().lock().unwrap().get(&id).and_then(Weak::upgrade);
126 let Some(p) = p else {
127 return ResourceResponse::error(410);
128 };
129 let base = ResourceProvider(p.clone()).base_url();
130 let Ok(parsed) = url::Url::parse(url) else {
132 return ResourceResponse::error(400);
133 };
134 let relative = if cfg!(target_arch = "wasm32") {
135 parsed
136 .path()
137 .split_once(&format!(
138 "/assets/data/day-piece-webview/{}/{id}/",
139 browser_namespace()
140 ))
141 .map(|(_, p)| p)
142 } else {
143 url.strip_prefix(&base)
144 .map(|s| s.split(['?', '#']).next().unwrap_or(""))
145 };
146 let Some(relative) = relative else {
147 return ResourceResponse::error(403);
148 };
149 let Ok(path) = percent_encoding::percent_decode_str(relative).decode_utf8() else {
150 return ResourceResponse::error(400);
151 };
152 if path.starts_with('/')
153 || path.contains(['\\', '\0'])
154 || path.split('/').any(|s| s == ".." || s == ".")
155 {
156 return ResourceResponse::error(403);
157 }
158 if method != "GET" && method != "HEAD" {
159 return ResourceResponse::error(405);
160 }
161 let request = ResourceRequest {
162 path: path.into_owned(),
163 query: parsed.query().map(str::to_owned),
164 method: method.into(),
165 };
166 let mut r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (p.handler)(request)))
167 .unwrap_or_else(|_| ResourceResponse::error(500));
168 if !(200..=599).contains(&r.status)
171 || (300..400).contains(&r.status)
172 || r.mime.contains(['\r', '\n', '\0'])
173 {
174 return ResourceResponse::error(500);
175 }
176 r.headers.retain(|(k, v)| {
177 !k.is_empty()
178 && k.bytes()
179 .all(|c| c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c))
180 && v.chars()
181 .all(|c| c == '\t' || (' '..='\u{ff}').contains(&c) && c != '\u{7f}')
182 && ![
183 "content-type",
184 "content-length",
185 "content-range",
186 "accept-ranges",
187 "cache-control",
188 "x-content-type-options",
189 ]
190 .iter()
191 .any(|n| k.eq_ignore_ascii_case(n))
192 });
193 r.headers.push(("Cache-Control".into(), "no-store".into()));
194 r.headers
195 .push(("X-Content-Type-Options".into(), "nosniff".into()));
196 if r.status == 204 || r.status == 205 {
197 r.body = Arc::from([]);
198 }
199 let len = r.body.len();
200 if r.status == 200 {
201 r.headers.push(("Accept-Ranges".into(), "bytes".into()));
202 if !range.is_empty() && method == "GET" {
203 if let Some((a, b)) = byte_range(range, len) {
204 r.status = 206;
205 r.body = Arc::from(&r.body[a..=b]);
206 r.headers
207 .push(("Content-Range".into(), format!("bytes {a}-{b}/{len}")));
208 } else {
209 r.status = 416;
210 r.body = Arc::from([]);
211 r.headers
212 .push(("Content-Range".into(), format!("bytes */{len}")));
213 }
214 }
215 }
216 r.headers
217 .push(("Content-Length".into(), r.body.len().to_string()));
218 if method == "HEAD" {
219 r.body = Arc::from([]);
220 }
221 r
222}
223fn byte_range(s: &str, len: usize) -> Option<(usize, usize)> {
224 if len == 0 {
225 return None;
226 }
227 let (a, b) = s.strip_prefix("bytes=")?.split_once('-')?;
228 if a.is_empty() {
229 let n = b.parse::<usize>().ok()?;
230 return (n > 0).then_some((len.saturating_sub(n), len - 1));
231 }
232 let a: usize = a.parse().ok()?;
233 let b = if b.is_empty() {
234 len - 1
235 } else {
236 b.parse::<usize>().ok()?.min(len - 1)
237 };
238 (a <= b && a < len).then_some((a, b))
239}
240
241#[cfg(not(target_arch = "wasm32"))]
242pub(crate) fn background(work: impl FnOnce() + Send + 'static) {
243 type Work = Box<dyn FnOnce() + Send>;
244 static QUEUE: OnceLock<std::sync::mpsc::Sender<Work>> = OnceLock::new();
245 let queue = QUEUE.get_or_init(|| {
246 let (tx, rx) = std::sync::mpsc::channel::<Work>();
247 let rx = Arc::new(Mutex::new(rx));
248 for _ in 0..4 {
249 let rx = rx.clone();
250 std::thread::spawn(move || {
251 loop {
252 let work = rx.lock().unwrap().recv();
253 let Ok(work) = work else { break };
254 work();
255 }
256 });
257 }
258 tx
259 });
260 let _ = queue.send(Box::new(work));
263}
264
265#[repr(C)]
267pub(crate) struct WireResponse {
268 pub status: u16,
269 pub mime: std::ffi::CString,
270 pub headers: std::ffi::CString,
271 pub response: ResourceResponse,
272}
273pub(crate) fn wire(r: ResourceResponse) -> Box<WireResponse> {
274 let headers = r
275 .headers
276 .iter()
277 .map(|(k, v)| format!("{k}: {v}\r\n"))
278 .collect::<String>();
279 Box::new(WireResponse {
280 status: r.status,
281 mime: std::ffi::CString::new(r.mime.as_str()).unwrap_or_default(),
282 headers: std::ffi::CString::new(headers).unwrap_or_default(),
283 response: r,
284 })
285}
286unsafe fn cstr<'a>(s: *const std::ffi::c_char) -> &'a str {
287 if s.is_null() {
288 ""
289 } else {
290 unsafe { std::ffi::CStr::from_ptr(s) }
291 .to_str()
292 .unwrap_or("")
293 }
294}
295#[unsafe(no_mangle)]
296pub unsafe extern "C" fn day_web_resource_read(
297 id: u64,
298 url: *const std::ffi::c_char,
299 method: *const std::ffi::c_char,
300 range: *const std::ffi::c_char,
301) -> *mut WireResponse {
302 Box::into_raw(wire(respond(
303 id,
304 unsafe { cstr(url) },
305 unsafe { cstr(method) },
306 unsafe { cstr(range) },
307 )))
308}
309#[unsafe(no_mangle)]
310pub unsafe extern "C" fn day_web_resource_free(r: *mut WireResponse) {
311 if !r.is_null() {
312 drop(unsafe { Box::from_raw(r) })
313 }
314}
315#[unsafe(no_mangle)]
316pub unsafe extern "C" fn day_web_resource_status(r: &WireResponse) -> u16 {
317 r.status
318}
319#[unsafe(no_mangle)]
320pub unsafe extern "C" fn day_web_resource_mime(r: &WireResponse) -> *const std::ffi::c_char {
321 r.mime.as_ptr()
322}
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn day_web_resource_headers(r: &WireResponse) -> *const std::ffi::c_char {
325 r.headers.as_ptr()
326}
327#[unsafe(no_mangle)]
328pub unsafe extern "C" fn day_web_resource_data(r: &WireResponse) -> *const u8 {
329 r.response.body.as_ptr()
330}
331#[unsafe(no_mangle)]
332pub unsafe extern "C" fn day_web_resource_len(r: &WireResponse) -> usize {
333 r.response.body.len()
334}
335
336#[cfg(not(target_arch = "wasm32"))]
337#[unsafe(no_mangle)]
338pub unsafe extern "C" fn day_web_resource_start(
339 id: u64,
340 url: *const std::ffi::c_char,
341 method: *const std::ffi::c_char,
342 range: *const std::ffi::c_char,
343 context: *mut std::ffi::c_void,
344 done: extern "C" fn(*mut std::ffi::c_void, *mut WireResponse),
345) {
346 let (url, method, range) = unsafe {
347 (
348 cstr(url).to_owned(),
349 cstr(method).to_owned(),
350 cstr(range).to_owned(),
351 )
352 };
353 let context = context as usize;
354 background(move || {
355 let r = Box::into_raw(wire(respond(id, &url, &method, &range)));
356 done(context as *mut _, r);
357 });
358}
359
360#[cfg(all(feature = "dom", target_arch = "wasm32"))]
361fn browser_namespace() -> String {
362 crate::browser::namespace()
363}
364#[cfg(not(all(feature = "dom", target_arch = "wasm32")))]
365fn browser_namespace() -> String {
366 String::new()
367}
368
369impl ResourceProvider {
370 pub fn with_site(
374 site: impl crate::IntoInlineSite,
375 handler: impl Fn(ResourceRequest) -> ResourceResponse + Send + Sync + 'static,
376 ) -> Self {
377 let root = site.into_inline_site().root;
378 Self::new(move |request| {
379 let Some(path) = request.path.strip_prefix("__day_assets/") else {
380 return handler(request);
381 };
382 let name = format!("{root}/{path}");
383 #[cfg(target_arch = "wasm32")]
384 {
385 let mut r = ResourceResponse::new("application/octet-stream", Vec::new());
386 r.headers.push(("X-Day-Bundled-Asset".into(), name));
387 r
388 }
389 #[cfg(not(target_arch = "wasm32"))]
390 {
391 match day_spec::resource(day_spec::AssetName::dynamic(name)) {
392 Some(resource) => {
393 ResourceResponse::new(mime_for_path(path), resource.as_slice().to_vec())
394 }
395 None => ResourceResponse::not_found(),
396 }
397 }
398 })
399 }
400}
401#[cfg(not(target_arch = "wasm32"))]
402fn mime_for_path(path: &str) -> &'static str {
403 match path.rsplit('.').next().unwrap_or("") {
404 "html" | "htm" => "text/html",
405 "xhtml" => "application/xhtml+xml",
406 "css" => "text/css",
407 "js" | "mjs" => "text/javascript",
408 "json" => "application/json",
409 "wasm" => "application/wasm",
410 "svg" => "image/svg+xml",
411 "png" => "image/png",
412 "jpg" | "jpeg" => "image/jpeg",
413 "webp" => "image/webp",
414 "gif" => "image/gif",
415 "woff" => "font/woff",
416 "woff2" => "font/woff2",
417 "ttf" => "font/ttf",
418 "otf" => "font/otf",
419 _ => "application/octet-stream",
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 #[test]
427 fn namespace_validation_precedes_callback_and_preserves_queries() {
428 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
429 let count = calls.clone();
430 let p = ResourceProvider::new(move |r| {
431 count.fetch_add(1, Ordering::Relaxed);
432 assert_eq!(r.path, "text/café #1.xhtml");
433 assert_eq!(r.query.as_deref(), Some("mode=print"));
434 ResourceResponse::new("text/html", Vec::new())
435 });
436 let other = ResourceProvider::new(|_| unreachable!("wrong provider"));
437 for path in [
438 "%2E%2E/private",
439 "a/%2e%2e/b",
440 "%2Fprivate",
441 "a%5Cb",
442 "%00",
443 "%FF",
444 ] {
445 assert!(respond(p.id(), &format!("{}{path}", p.base_url()), "GET", "").status >= 400);
446 }
447 assert_eq!(respond(p.id(), &other.url("x"), "GET", "").status, 403);
448 assert_eq!(respond(p.id(), &p.url("x"), "POST", "").status, 405);
449 assert_eq!(calls.load(Ordering::Relaxed), 0);
450 assert_eq!(
451 respond(
452 p.id(),
453 &(p.url("text/café #1.xhtml") + "?mode=print#anchor"),
454 "GET",
455 ""
456 )
457 .status,
458 200
459 );
460 assert_eq!(calls.load(Ordering::Relaxed), 1);
461 }
462 #[test]
463 fn shared_bytes_head_and_response_headers() {
464 let bytes: Arc<[u8]> = Arc::from([0, 1, 255]);
465 let body = bytes.clone();
466 let p = ResourceProvider::new(move |_| {
467 let mut r = ResourceResponse::new("application/octet-stream", body.clone());
468 r.headers = vec![
469 ("X-Fixture".into(), "yes".into()),
470 ("Content-Length".into(), "999".into()),
471 ("Bad:Name".into(), "x".into()),
472 ("X-Bad".into(), "x\r\ny".into()),
473 ];
474 r
475 });
476 let r = respond(p.id(), &p.url("bytes"), "GET", "");
477 assert!(Arc::ptr_eq(&r.body, &bytes));
478 let h: HashMap<_, _> = r.headers.into_iter().collect();
479 assert_eq!(h["Content-Length"], "3");
480 assert_eq!(h["X-Fixture"], "yes");
481 assert!(!h.contains_key("Bad:Name") && !h.contains_key("X-Bad"));
482 let head = respond(p.id(), &p.url("bytes"), "HEAD", "");
483 assert!(head.body.is_empty());
484 assert!(
485 head.headers
486 .contains(&("Content-Length".into(), "3".into()))
487 );
488 }
489 #[test]
490 fn provider_can_use_registry_reentrantly_from_parallel_requests() {
491 let p = ResourceProvider::new(|_| {
492 let nested = ResourceProvider::new(|_| ResourceResponse::not_found());
493 assert_eq!(
494 respond(nested.id(), &nested.url("missing"), "GET", "").status,
495 404
496 );
497 ResourceResponse::new("text/plain", vec![42])
498 });
499 let tasks: Vec<_> = (0..8)
500 .map(|_| {
501 let p = p.clone();
502 std::thread::spawn(move || respond(p.id(), &p.url("fixture"), "GET", "").body)
503 })
504 .collect();
505 for task in tasks {
506 assert_eq!(&*task.join().unwrap(), &[42]);
507 }
508 }
509 #[test]
510 fn errors_and_empty_statuses_are_portable() {
511 for status in [100, 302, 600] {
512 let p = ResourceProvider::new(move |_| ResourceResponse::error(status));
513 assert_eq!(respond(p.id(), &p.url("fixture"), "GET", "").status, 500);
514 }
515 for status in [204, 205] {
516 let p = ResourceProvider::new(move |_| ResourceResponse {
517 status,
518 ..ResourceResponse::new("text/plain", vec![1])
519 });
520 assert!(
521 respond(p.id(), &p.url("fixture"), "GET", "")
522 .body
523 .is_empty()
524 );
525 }
526 let p = ResourceProvider::new(|_| panic!("synthetic provider failure"));
527 assert_eq!(respond(p.id(), &p.url("fixture"), "GET", "").status, 500);
528 }
529 #[test]
530 fn relative_paths_binary_ranges_and_lifetime() {
531 let p = ResourceProvider::new(|r| {
532 assert_eq!(r.path, "images/a b.png");
533 ResourceResponse::new("image/png", vec![0, 1, 2, 255])
534 });
535 let u = p.url("images/a b.png");
536 assert_eq!(&*respond(p.id(), &u, "GET", "").body, &[0, 1, 2, 255]);
537 let r = respond(p.id(), &u, "GET", "bytes=1-2");
538 assert_eq!(r.status, 206);
539 assert_eq!(&*r.body, &[1, 2]);
540 assert_eq!(respond(p.id(), &u, "GET", "bytes=99-").status, 416);
541 assert!(respond(p.id(), &u, "HEAD", "").body.is_empty());
542 assert_eq!(
543 respond(
544 p.id(),
545 &format!("{}%2e%2e/private", p.base_url()),
546 "GET",
547 ""
548 )
549 .status,
550 403
551 );
552 assert_eq!(
553 respond(p.id(), "https://elsewhere.invalid/", "GET", "").status,
554 403
555 );
556 let id = p.id();
557 drop(p);
558 assert_eq!(respond(id, &u, "GET", "").status, 410);
559 }
560 #[test]
561 fn ranges() {
562 assert_eq!(byte_range("bytes=-2", 4), Some((2, 3)));
563 assert_eq!(byte_range("bytes=1-", 4), Some((1, 3)));
564 assert_eq!(byte_range("bytes=1-2,3-4", 8), None);
565 }
566}