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 fedimint_core::hex::ToHex;
11use fedimint_core::secp256k1::rand::{Rng, thread_rng};
12use maud::{DOCTYPE, Markup, html};
13use serde::Deserialize;
14use tokio::net::TcpStream;
15use tokio::time::timeout;
16
17pub const ROOT_ROUTE: &str = "/";
18pub const LOGIN_ROUTE: &str = "/login";
19pub const CONNECTIVITY_CHECK_ROUTE: &str = "/ui/connectivity-check";
20
21/// Generic state for both setup and dashboard UIs
22#[derive(Clone)]
23pub struct UiState<T> {
24    pub api: T,
25    pub auth_cookie_name: String,
26    pub auth_cookie_value: String,
27}
28
29impl<T> UiState<T> {
30    pub fn new(api: T) -> Self {
31        Self {
32            api,
33            auth_cookie_name: thread_rng().r#gen::<[u8; 4]>().encode_hex(),
34            auth_cookie_value: thread_rng().r#gen::<[u8; 32]>().encode_hex(),
35        }
36    }
37}
38
39pub fn common_head(title: &str) -> Markup {
40    html! {
41        meta charset="utf-8";
42        meta name="viewport" content="width=device-width, initial-scale=1.0";
43        link rel="stylesheet" href="/assets/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous";
44        link rel="stylesheet" type="text/css" href="/assets/style.css";
45        link rel="icon" type="image/png" href="/assets/logo.png";
46
47        // Note: this needs to be included in the header, so that web-page does not
48        // get in a state where htmx is not yet loaded. `deref` helps with blocking the load.
49        // Learned the hard way. --dpc
50        script defer src="/assets/htmx.org-2.0.4.min.js" {}
51
52        title { (title) }
53    }
54}
55
56#[derive(Debug, Deserialize)]
57pub struct LoginInput {
58    pub password: String,
59}
60
61pub fn login_layout(title: &str, content: Markup) -> Markup {
62    html! {
63        (DOCTYPE)
64        html {
65            head {
66                (common_head(title))
67            }
68            body {
69                div class="container" {
70                    div class="row justify-content-center" {
71                        div class="col-md-8 col-lg-5 narrow-container" {
72                            header class="text-center" {
73                                h1 class="header-title" { (title) }
74                            }
75
76                            div class="card" {
77                                div class="card-body" {
78                                    (content)
79                                }
80                            }
81                        }
82                    }
83                }
84                script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
85            }
86        }
87    }
88}
89
90pub fn login_form_response(title: &str) -> impl IntoResponse {
91    let content = html! {
92        form method="post" action=(LOGIN_ROUTE) {
93            div class="form-group mb-4" {
94                input type="password" class="form-control" id="password" name="password" placeholder="Your password" required;
95            }
96            div class="button-container" {
97                button type="submit" class="btn btn-primary setup-btn" { "Log In" }
98            }
99        }
100    };
101
102    Html(login_layout(title, content).into_string()).into_response()
103}
104
105pub fn dashboard_layout(content: Markup, title: &str, version: Option<&str>) -> Markup {
106    html! {
107        (DOCTYPE)
108        html {
109            head {
110                (common_head(title))
111            }
112            body {
113                div class="container" {
114                    header class="text-center mb-4" {
115                        h1 class="header-title mb-1" { (title) }
116                        @if let Some(version) = version {
117                            div {
118                                small class="text-muted" { "v" (version) }
119                            }
120                        }
121                    }
122
123                    (content)
124                }
125                (connectivity_widget())
126                script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
127            }
128        }
129    }
130}
131
132/// Fixed-position div that loads the connectivity status fragment via htmx.
133pub fn connectivity_widget() -> Markup {
134    html! {
135        div
136            style="position: fixed; bottom: 1rem; right: 1rem; z-index: 1050;"
137            hx-get=(CONNECTIVITY_CHECK_ROUTE)
138            hx-trigger="load, every 30s"
139            hx-swap="innerHTML"
140        {}
141    }
142}
143
144async fn check_tcp_connect(addr: SocketAddr) -> bool {
145    timeout(Duration::from_secs(3), TcpStream::connect(addr))
146        .await
147        .is_ok_and(|r| r.is_ok())
148}
149
150/// Handler that checks internet connectivity by attempting TCP connections
151/// to well-known anycast IPs and returns an HTML fragment.
152/// Manually checks auth cookie to avoid `UserAuth` extractor's redirect,
153/// which would cause htmx to swap the entire login page into the widget.
154pub async fn connectivity_check_handler<Api: Send + Sync + 'static>(
155    State(state): State<UiState<Api>>,
156    jar: CookieJar,
157) -> Html<String> {
158    // Check auth manually — return empty fragment if not authenticated
159    let authenticated = jar
160        .get(&state.auth_cookie_name)
161        .is_some_and(|c| c.value() == state.auth_cookie_value);
162
163    if !authenticated {
164        return Html(String::new());
165    }
166
167    let check_1 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443));
168    let check_2 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53));
169
170    let (r1, r2) = tokio::join!(check_1, check_2);
171    let is_connected = r1 || r2;
172
173    let markup = if is_connected {
174        html! {
175            span class="badge bg-success" style="font-size: 0.75rem;" {
176                "Internet connection OK"
177            }
178        }
179    } else {
180        html! {
181            span class="badge bg-danger" style="font-size: 0.75rem;" {
182                "Internet connection unavailable"
183            }
184        }
185    };
186
187    Html(markup.into_string())
188}