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