fedimint_ui_common/
lib.rs1pub 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#[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 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
132pub 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
150pub async fn connectivity_check_handler<Api: Send + Sync + 'static>(
155 State(state): State<UiState<Api>>,
156 jar: CookieJar,
157) -> Html<String> {
158 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}