Skip to main content

fedimint_ui_common/
lib.rs

1pub mod assets;
2pub mod auth;
3
4use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5use std::time::Duration;
6
7use axum::extract::State;
8use axum::response::{Html, IntoResponse};
9use axum_extra::extract::CookieJar;
10use axum_extra::extract::cookie::{Cookie, SameSite};
11use fedimint_core::hex::ToHex;
12use fedimint_core::module::ApiAuth;
13use fedimint_core::secp256k1::rand::{Rng, thread_rng};
14use maud::{DOCTYPE, Markup, PreEscaped, html};
15use serde::Deserialize;
16use subtle::ConstantTimeEq as _;
17use tokio::net::TcpStream;
18use tokio::time::timeout;
19
20pub const ROOT_ROUTE: &str = "/";
21pub const LOGIN_ROUTE: &str = "/login";
22pub const CONNECTIVITY_CHECK_ROUTE: &str = "/ui/connectivity-check";
23
24/// Generic state for both setup and dashboard UIs
25#[derive(Clone)]
26pub struct UiState<T> {
27    pub api: T,
28    pub auth_cookie_name: String,
29    pub auth_cookie_value: String,
30    /// Whether the UI requires a password login. When `false` (passwordless
31    /// mode), the `UserAuth` extractor auto-passes and the `/login` route
32    /// should not be mounted.
33    pub requires_auth: bool,
34}
35
36impl<T> UiState<T> {
37    pub fn new(api: T, requires_auth: bool) -> Self {
38        Self {
39            api,
40            auth_cookie_name: thread_rng().r#gen::<[u8; 4]>().encode_hex(),
41            auth_cookie_value: thread_rng().r#gen::<[u8; 32]>().encode_hex(),
42            requires_auth,
43        }
44    }
45
46    pub(crate) fn has_valid_auth_cookie(&self, jar: &CookieJar) -> bool {
47        jar.get(&self.auth_cookie_name).is_some_and(|cookie| {
48            bool::from(
49                cookie
50                    .value()
51                    .as_bytes()
52                    .ct_eq(self.auth_cookie_value.as_bytes()),
53            )
54        })
55    }
56}
57
58pub fn common_head(title: &str) -> Markup {
59    html! {
60        meta charset="utf-8";
61        meta name="viewport" content="width=device-width, initial-scale=1.0";
62        link rel="stylesheet" href="/assets/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous";
63        link rel="stylesheet" href="/assets/bootstrap-icons.min.css";
64        link rel="stylesheet" type="text/css" href="/assets/style.css";
65        link rel="icon" type="image/png" href="/assets/logo.png";
66
67        // Note: this needs to be included in the header, so that web-page does not
68        // get in a state where htmx is not yet loaded. `deref` helps with blocking the load.
69        // Learned the hard way. --dpc
70        script defer src="/assets/htmx.org-2.0.4.min.js" {}
71
72        title { (title) }
73
74        script {
75            (PreEscaped(r#"
76            function copyText(text, btn) {
77                if (navigator.clipboard) {
78                    navigator.clipboard.writeText(text).then(function() {
79                        showCopied(btn);
80                    });
81                } else {
82                    var ta = document.createElement('textarea');
83                    ta.value = text;
84                    ta.style.position = 'fixed';
85                    ta.style.opacity = '0';
86                    document.body.appendChild(ta);
87                    ta.select();
88                    document.execCommand('copy');
89                    document.body.removeChild(ta);
90                    showCopied(btn);
91                }
92            }
93            function showCopied(btn) {
94                if (!btn) return;
95                btn.classList.add('copied');
96                var icon = btn.innerHTML;
97                btn.innerHTML = '<i class="bi bi-check-lg"></i>';
98                setTimeout(function() {
99                    btn.innerHTML = icon;
100                    btn.classList.remove('copied');
101                }, 2000);
102            }
103            "#))
104        }
105    }
106}
107
108#[derive(Debug, Deserialize)]
109pub struct LoginInput {
110    pub password: String,
111}
112
113pub fn single_card_layout(header: &str, content: Markup) -> Markup {
114    card_layout("col-md-8 col-lg-5 narrow-container", header, content, None)
115}
116
117/// Variant of [`single_card_layout`] that renders a version footer at the
118/// bottom of the page.
119pub fn single_card_layout_with_version(
120    header: &str,
121    content: Markup,
122    version: &str,
123    version_hash: Option<&str>,
124) -> Markup {
125    card_layout(
126        "col-md-8 col-lg-5 narrow-container",
127        header,
128        content,
129        Some(version_footer(version, version_hash)),
130    )
131}
132
133fn card_layout(col_class: &str, header: &str, content: Markup, footer: Option<Markup>) -> Markup {
134    html! {
135        (DOCTYPE)
136        html {
137            head {
138                (common_head("Fedimint"))
139            }
140            body class="d-flex flex-column min-vh-100" {
141                div class="container my-auto" {
142                    div class="row justify-content-center" {
143                        div class=(col_class) {
144                            div class="card" {
145                                div class="card-header dashboard-header" { (header) }
146                                div class="card-body" {
147                                    (content)
148                                }
149                            }
150                        }
151                    }
152                }
153                @if let Some(footer) = footer {
154                    (footer)
155                }
156                (connectivity_widget())
157                script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
158            }
159        }
160    }
161}
162
163/// Renders a readonly input with a copy-to-clipboard button using
164/// Bootstrap's input-group pattern.
165pub fn copiable_text(text: &str) -> Markup {
166    html! {
167        div class="input-group" {
168            input type="text" class="form-control form-control-sm font-monospace"
169                value=(text) readonly;
170            button type="button" class="btn btn-outline-secondary"
171                onclick=(format!("copyText('{}', this)", text)) {
172                i class="bi bi-clipboard" {}
173            }
174        }
175    }
176}
177
178pub fn login_form(error: Option<&str>) -> Markup {
179    html! {
180        form id="login-form" hx-post=(LOGIN_ROUTE) hx-target="#login-form" hx-swap="outerHTML" {
181            div class="form-group mb-3" {
182                input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required autofocus;
183            }
184            @if let Some(error) = error {
185                div class="alert alert-danger mb-3" { (error) }
186            }
187            button type="submit" class="btn btn-primary w-100 py-2" { "Continue" }
188        }
189    }
190}
191
192pub fn login_submit_response(
193    auth: ApiAuth,
194    auth_cookie_name: String,
195    auth_cookie_value: String,
196    jar: CookieJar,
197    input: LoginInput,
198) -> impl IntoResponse {
199    if auth.verify(&input.password) {
200        let mut cookie = Cookie::new(auth_cookie_name, auth_cookie_value);
201
202        cookie.set_http_only(true);
203        cookie.set_same_site(Some(SameSite::Lax));
204
205        return (jar.add(cookie), [("HX-Redirect", "/")]).into_response();
206    }
207
208    Html(login_form(Some("The password is invalid")).into_string()).into_response()
209}
210
211pub fn dashboard_layout(content: Markup, version: &str, version_hash: Option<&str>) -> Markup {
212    html! {
213        (DOCTYPE)
214        html {
215            head {
216                (common_head("Fedimint"))
217            }
218            body {
219                div class="container" {
220                    (content)
221                }
222                (version_footer(version, version_hash))
223                (connectivity_widget())
224                script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
225            }
226        }
227    }
228}
229
230/// Renders the version line shown at the bottom of guardian admin pages.
231/// `version_hash` is rendered next to the version in monospace when present.
232pub fn version_footer(version: &str, version_hash: Option<&str>) -> Markup {
233    html! {
234        div class="text-center mt-4 mb-3" {
235            span class="text-muted" { "Version " (version) }
236            @if let Some(hash) = version_hash {
237                @let short_hash: String = hash.chars().take(7).collect();
238                span class="text-muted ms-2 font-monospace" style="font-size: 0.85em;" {
239                    "(" (short_hash) ")"
240                }
241            }
242        }
243    }
244}
245
246/// Fixed-position div that loads the connectivity status fragment via htmx.
247pub fn connectivity_widget() -> Markup {
248    html! {
249        div
250            style="position: fixed; bottom: 1rem; right: 1rem; z-index: 1050;"
251            hx-get=(CONNECTIVITY_CHECK_ROUTE)
252            hx-trigger="load, every 30s"
253            hx-swap="innerHTML"
254        {}
255    }
256}
257
258async fn check_tcp_connect(addr: SocketAddr) -> bool {
259    timeout(Duration::from_secs(3), TcpStream::connect(addr))
260        .await
261        .is_ok_and(|r| r.is_ok())
262}
263
264/// Handler that checks internet connectivity by attempting TCP connections
265/// to well-known anycast IPs and returns an HTML fragment.
266/// Manually checks auth cookie to avoid `UserAuth` extractor's redirect,
267/// which would cause htmx to swap the entire login page into the widget.
268pub async fn connectivity_check_handler<Api: Send + Sync + 'static>(
269    State(state): State<UiState<Api>>,
270    jar: CookieJar,
271) -> Html<String> {
272    // Return an empty fragment instead of redirecting so htmx leaves the widget
273    // empty when the UI is not authenticated.
274    if state.requires_auth && !state.has_valid_auth_cookie(&jar) {
275        return Html(String::new());
276    }
277
278    let check_1 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443));
279    let check_2 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53));
280
281    let (r1, r2) = tokio::join!(check_1, check_2);
282    let is_connected = r1 || r2;
283
284    let markup = if is_connected {
285        html! {
286            span class="badge bg-success" style="font-size: 0.75rem;" {
287                "Internet connection OK"
288            }
289        }
290    } else {
291        html! {
292            span class="badge bg-danger" style="font-size: 0.75rem;" {
293                "Internet connection unavailable"
294            }
295        }
296    };
297
298    Html(markup.into_string())
299}
300
301#[cfg(test)]
302mod tests {
303    use axum_extra::extract::CookieJar;
304    use axum_extra::extract::cookie::Cookie;
305
306    use super::UiState;
307
308    #[test]
309    fn authentication_requires_matching_cookie() {
310        let state = UiState {
311            api: (),
312            auth_cookie_name: "session".to_owned(),
313            auth_cookie_value: "expected-value".to_owned(),
314            requires_auth: true,
315        };
316
317        let valid = CookieJar::new().add(Cookie::new("session", "expected-value"));
318        let wrong_value = CookieJar::new().add(Cookie::new("session", "wrong-value"));
319        let wrong_name = CookieJar::new().add(Cookie::new("other", "expected-value"));
320
321        assert!(state.has_valid_auth_cookie(&valid));
322        assert!(!state.has_valid_auth_cookie(&wrong_value));
323        assert!(!state.has_valid_auth_cookie(&wrong_name));
324        assert!(!state.has_valid_auth_cookie(&CookieJar::new()));
325    }
326}