spa.rs
⎇
Raw
1use axum::http::Uri;
2use axum::http::{HeaderMap, Method, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4
5const DEV_HINT: &str = r#"<!doctype html>
6<html lang="en"><head><meta charset="utf-8"><title>dovenest</title></head>
7<body style="font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#f6f7f9">
8<div style="max-width:520px;padding:24px;border:1px solid #e5e7eb;border-radius:12px;background:#fff">
9<h1>dovenest</h1>
10<p>The frontend has not been built yet.</p>
11<p>In development, run <code>just dev-web</code> in a second terminal and open
12<code>http://localhost:8080</code> (Trunk serves the app and proxies <code>/api</code> to this backend).</p>
13<p>If you hit this page directly from the backend, make sure Trunk's output
14directory is visible — the server looks in <code>target/web</code> or <code>$DOVENEST_DIST</code>.</p>
15<p>For the production single binary: <code>just build</code>.</p>
16</div></body></html>"#;
17
18/// Serve the frontend: static assets, with a fallback to index.html so that
19/// client-side routes (e.g. `/s/<token>`) work.
20pub(super) async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response {
21 let path = uri.path();
22 if path.starts_with("/api/") {
23 return (StatusCode::NOT_FOUND, "unknown endpoint").into_response();
24 }
25 // CalDAV and CardDAV clients given only the server's address ask it for
26 // the principal (RFC 6764, 6).
27 if path == "/" {
28 match method.as_str() {
29 "PROPFIND" => {
30 return (
31 StatusCode::TEMPORARY_REDIRECT,
32 [(header::LOCATION, format!("{}/", api_types::PIM))],
33 )
34 .into_response();
35 }
36 "OPTIONS" => {
37 return (
38 StatusCode::OK,
39 [
40 (header::ALLOW, "OPTIONS, GET, HEAD, PROPFIND"),
41 (
42 header::HeaderName::from_static("dav"),
43 super::pim::COMPLIANCE,
44 ),
45 ],
46 )
47 .into_response();
48 }
49 _ => {}
50 }
51 }
52 if method != Method::GET && method != Method::HEAD {
53 return StatusCode::METHOD_NOT_ALLOWED.into_response();
54 }
55
56 let rel = path.trim_start_matches('/');
57 let asset = if rel.is_empty() {
58 crate::assets::get_asset("index.html")
59 } else {
60 crate::assets::get_asset(rel)
61 };
62
63 match asset {
64 Some((bytes, mime, cache, etag)) => {
65 // The embedded assets carry a content hash, so a repeat visit can
66 // be answered without sending the body again.
67 if let Some(tag) = &etag
68 && headers
69 .get(header::IF_NONE_MATCH)
70 .and_then(|v| v.to_str().ok())
71 .is_some_and(|v| v.split(',').any(|t| t.trim() == tag || t.trim() == "*"))
72 {
73 return (
74 StatusCode::NOT_MODIFIED,
75 [(header::CACHE_CONTROL, cache), (header::ETAG, tag.clone())],
76 )
77 .into_response();
78 }
79 let mut res = (
80 [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
81 bytes,
82 )
83 .into_response();
84 if let Some(tag) = etag
85 && let Ok(v) = tag.parse()
86 {
87 res.headers_mut().insert(header::ETAG, v);
88 }
89 res
90 }
91 None => match crate::assets::get_asset("index.html") {
92 Some((bytes, mime, _, _)) => (
93 [
94 (header::CONTENT_TYPE, mime),
95 (header::CACHE_CONTROL, "no-cache".to_string()),
96 ],
97 bytes,
98 )
99 .into_response(),
100 None => DEV_HINT.into_response(),
101 },
102 }
103}
104