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