fedimint_server_ui/dashboard/modules/
meta.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2
3use axum::extract::{Form, FromRequest, State};
4use axum::http::StatusCode;
5use axum::response::{Html, IntoResponse, Response};
6use fedimint_core::PeerId;
7use fedimint_core::module::serde_json::{self, Value};
8use fedimint_meta_server::Meta;
9use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
10use fedimint_ui_common::UiState;
11use fedimint_ui_common::auth::UserAuth;
12use maud::{Markup, html};
13use serde::Serialize;
14use thiserror::Error;
15use tracing::{debug, warn};
16
17use crate::LOG_UI;
18
19// Meta route constants
20pub const META_SUBMIT_ROUTE: &str = "/meta/submit";
21pub const META_SET_ROUTE: &str = "/meta/set";
22pub const META_RESET_ROUTE: &str = "/meta/reset";
23pub const META_DELETE_ROUTE: &str = "/meta/delete";
24
25// Function to render the Meta module UI section
26pub async fn render(meta: &Meta) -> Markup {
27    // Get current consensus value
28    let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
29    // Get current revision number
30    let revision = meta
31        .handle_get_consensus_revision_request_ui()
32        .await
33        .ok()
34        .unwrap_or(0);
35    // Get current submissions from all peers
36    let submissions = meta
37        .handle_get_submissions_request_ui()
38        .await
39        .ok()
40        .unwrap_or_default();
41
42    let current_meta_keys = if let Some(o) = submissions
43        .get(&meta.our_peer_id)
44        .cloned()
45        .or_else(|| consensus_value.clone())
46        .and_then(|v| v.as_object().cloned())
47    {
48        o
49    } else {
50        serde_json::Map::new()
51    };
52
53    html! {
54        div class="card h-100" {
55            div class="card-header dashboard-header" { "Meta Configuration" }
56            div class="card-body" {
57                div class="mb-4" {
58                    h5 { "Current Consensus (Revision: " (revision) ")" }
59                    @if let Some(value) = &consensus_value {
60                        pre class="bg-light p-3 user-select-all" {
61                            code {
62                                (serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string()))
63                            }
64                        }
65                    } @else {
66                        div class="alert alert-secondary" { "No consensus value has been established yet." }
67                    }
68                    div class="mb-4" {
69                        (render_meta_edit_form(current_meta_keys, false, MetaEditForm::default()))
70                    }
71
72                    (render_submissions_form(meta.our_peer_id, &submissions))
73                }
74            }
75        }
76    }
77}
78
79fn render_submissions_form(our_id: PeerId, submissions: &BTreeMap<PeerId, Value>) -> Markup {
80    let mut submissions_by_value: HashMap<String, BTreeSet<PeerId>> = HashMap::new();
81
82    for (peer_id, value) in submissions {
83        let value_str =
84            serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string());
85        submissions_by_value
86            .entry(value_str)
87            .or_default()
88            .insert(*peer_id);
89    }
90
91    html! {
92        div #meta-submissions hx-swap-oob=(true) {
93            @if !submissions.is_empty() {
94                h5 { "Current Peer Submissions" }
95                div class="table-responsive" {
96                    table class="table table-sm" {
97                        thead {
98                            tr {
99                                th { "Peer IDs" }
100                                th { "Submission" }
101                                th { "Actions" }
102                            }
103                        }
104                        tbody {
105                            @for (value_str, peer_ids) in submissions_by_value {
106                                tr {
107                                    td { (
108                                        peer_ids.iter()
109                                        .map(|n| n.to_string())
110                                        .collect::<Vec<String>>()
111                                        .join(", "))
112                                    }
113                                    td {
114                                        pre class="m-0 p-2 bg-light" style="max-height: 150px; overflow-y: auto;" {
115                                            code {
116                                                (value_str)
117                                            }
118                                        }
119                                    }
120                                    @if !peer_ids.contains(&our_id) {
121                                        td {
122                                            form method="post"
123                                                hx-post=(META_SUBMIT_ROUTE)
124                                                hx-swap="none"
125                                            {
126                                                input type="hidden" name="json_content"
127                                                    value=(value_str);
128                                                button type="submit" class="btn btn-sm btn-success" {
129                                                    "Accept This Submission"
130                                                }
131                                            }
132                                        }
133                                    }
134                                }
135                            }
136                        }
137                    }
138                }
139            }
140        }
141    }
142}
143
144// Form for meta value submission
145#[derive(serde::Deserialize, Default)]
146pub struct MetaEditForm {
147    pub json_content: String,
148    #[serde(default)]
149    pub add_key: String,
150    #[serde(default)]
151    pub add_value: String,
152    #[serde(default)]
153    pub delete_key: String,
154}
155
156impl MetaEditForm {
157    fn top_level_keys(&self) -> RequestResult<serde_json::Map<String, Value>> {
158        Ok(
159            if let Some(serde_json::Value::Object(o)) =
160                serde_json::from_slice(self.json_content.as_bytes())
161                    .map_err(|x| RequestError::BadRequest { source: x.into() })?
162            {
163                o
164            } else {
165                serde_json::Map::new()
166            },
167        )
168    }
169}
170
171pub async fn post_submit(
172    State(state): State<UiState<DynDashboardApi>>,
173    _auth: UserAuth,
174    Form(form): Form<MetaEditForm>,
175) -> RequestResult<Response> {
176    let meta_module = state.api.get_module::<Meta>().unwrap();
177
178    let top_level_keys = form.top_level_keys()?;
179    let top_level_object = Value::Object(top_level_keys.clone());
180
181    meta_module
182        .handle_submit_request_ui(top_level_object.clone())
183        .await
184        .inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
185        .map_err(|_err| RequestError::InternalError)?;
186
187    let mut submissions = meta_module
188        .handle_get_submissions_request_ui()
189        .await
190        .ok()
191        .unwrap_or_default();
192
193    submissions.insert(meta_module.our_peer_id, top_level_object);
194
195    let content = html! {
196        (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
197
198        // Re-render submission with our submission added, as it will take couple of milliseconds
199        // for it to get processed and it's confusing if it doesn't immediatel show up.
200        (render_submissions_form(meta_module.our_peer_id, &submissions))
201    };
202    Ok(Html(content.into_string()).into_response())
203}
204
205pub async fn post_reset(
206    State(state): State<UiState<DynDashboardApi>>,
207    _auth: UserAuth,
208    Form(_form): Form<MetaEditForm>,
209) -> RequestResult<Response> {
210    let meta_module = state.api.get_module::<Meta>().unwrap();
211
212    let consensus_value = meta_module
213        .handle_get_consensus_request_ui()
214        .await
215        .ok()
216        .flatten();
217
218    let top_level_keys = if let Some(serde_json::Value::Object(o)) = consensus_value {
219        o
220    } else {
221        serde_json::Map::new()
222    };
223    let top_level_object = Value::Object(top_level_keys.clone());
224
225    meta_module
226        .handle_submit_request_ui(top_level_object.clone())
227        .await
228        .inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
229        .map_err(|_err| RequestError::InternalError)?;
230
231    let mut submissions = meta_module
232        .handle_get_submissions_request_ui()
233        .await
234        .ok()
235        .unwrap_or_default();
236
237    submissions.remove(&meta_module.our_peer_id);
238
239    let content = html! {
240        (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
241
242        // Re-render submission with our submission added, as it will take couple of milliseconds
243        // for it to get processed and it's confusing if it doesn't immediatel show up.
244        (render_submissions_form(meta_module.our_peer_id, &submissions))
245    };
246    Ok(Html(content.into_string()).into_response())
247}
248
249pub async fn post_set(
250    _auth: UserAuth,
251    Form(mut form): Form<MetaEditForm>,
252) -> RequestResult<Response> {
253    let mut top_level_object = form.top_level_keys()?;
254
255    let key = form.add_key.trim();
256    let value = form.add_value.trim();
257    let value = serde_json::from_str(value)
258        .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
259
260    top_level_object.insert(key.to_string(), value);
261
262    form.add_key = "".into();
263    form.add_value = "".into();
264    let content = render_meta_edit_form(top_level_object, true, MetaEditForm::default());
265    Ok(Html(content.into_string()).into_response())
266}
267
268pub async fn post_delete(
269    _auth: UserAuth,
270    Form(mut form): Form<MetaEditForm>,
271) -> RequestResult<Response> {
272    let mut top_level_json = form.top_level_keys()?;
273
274    let key = form.delete_key.trim();
275
276    top_level_json.remove(key);
277    form.delete_key = "".into();
278
279    let content = render_meta_edit_form(top_level_json, true, form);
280    Ok(Html(content.into_string()).into_response())
281}
282
283// <https://fedibtc.github.io/fedi-docs/docs/fedi/meta_fields/federation-metadata-configurations>
284const WELL_KNOWN_KEYS: &[&str] = &[
285    "welcome_message",
286    "fedi:pinned_message",
287    "fedi:federation_icon_url",
288    "fedi:tos_url",
289    "fedi:default_currency",
290    "fedi:popup_end_timestamp",
291    "fedi:invite_codes_disabled",
292    "fedi:new_members_disabled",
293    "fedi:max_invoice_msats",
294    "fedi:max_balance_msats",
295    "fedi:max_stable_balance_msats",
296    "fedi:fedimods",
297    "fedi:default_group_chats",
298    "fedi:offline_wallet_disabled",
299];
300
301pub fn render_meta_edit_form(
302    mut top_level_json: serde_json::Map<String, Value>,
303    // was the value edited via set/delete
304    pending: bool,
305    form: MetaEditForm,
306) -> Markup {
307    top_level_json.sort_keys();
308
309    let known_keys: HashSet<String> = top_level_json
310        .keys()
311        .cloned()
312        .chain(WELL_KNOWN_KEYS.iter().map(ToString::to_string))
313        .collect();
314    html! {
315        form #meta-edit-form hx-swap-oob=(true) {
316            h5 {
317                "Proposal"
318                @if pending {
319                    " (Pending)"
320                }
321            }
322            div class="input-group mb-2" {
323                textarea class="form-control" rows="15" readonly
324                    name="json_content"
325                {
326                    (serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
327                }
328            }
329            div class="input-group mb-2" {
330                input #add-key  type="text" class="form-control" placeholder="Key" aria-label="Key" list="keyOptions"
331                    // keys are usually shorter than values, so keep it small
332                    style="max-width: 250px;"
333                    name="add_key"
334                    value=(form.add_key)
335                {}
336                span class="input-group-text" { ":" }
337                input #add-value type="text" name="add_value" class="form-control" placeholder="Value" aria-label="Value"
338                    value=(form.add_value)
339                {}
340
341                datalist id="keyOptions" {
342                    @for key in known_keys {
343                        option value=(key) {}
344                    }
345                }
346
347                button class="btn btn-primary btn-min-width"
348                    type="button" id="button-set"
349                    title="Set a value in a meta proposal"
350                    hx-post=(META_SET_ROUTE)
351                    hx-swap="none"
352                    hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
353                { "Set" }
354            }
355            div class="input-group mb-2" {
356                select class="form-select"
357                    id="delete-key"
358                    name="delete_key"
359                {
360                    option value="" {}
361                    @for key in top_level_json.keys() {
362                        option value=(key) selected[key == &form.delete_key]{ (key) }
363                    }
364                }
365                button class="btn btn-primary btn-min-width"
366                    hx-post=(META_DELETE_ROUTE)
367                    hx-swap="none"
368                    hx-trigger="click, keypress[key=='Enter'] from:#delete-key"
369                    title="Delete a value in a meta proposal"
370                { "Delete" }
371            }
372            div class="d-flex justify-content-between btn-min-width" {
373                button class="btn btn-outline-warning me-5"
374                    title="Reset to current consensus"
375                    hx-post=(META_RESET_ROUTE)
376                    hx-swap="none"
377                { "Reset" }
378                button class="btn btn-success btn-min-width"
379                    hx-post=(META_SUBMIT_ROUTE)
380                    hx-swap="none"
381                    title="Submit new meta document for approval of other peers"
382                { "Submit" }
383            }
384        }
385    }
386}
387
388/// Wrapper over `T` to make it a json request response
389#[derive(FromRequest)]
390#[from_request(via(axum::Json), rejection(RequestError))]
391struct AppJson<T>(pub T);
392
393impl<T> IntoResponse for AppJson<T>
394where
395    axum::Json<T>: IntoResponse,
396{
397    fn into_response(self) -> Response {
398        axum::Json(self.0).into_response()
399    }
400}
401
402/// Whatever can go wrong with a request
403#[derive(Debug, Error)]
404pub enum RequestError {
405    #[error("Bad request: {source}")]
406    BadRequest { source: anyhow::Error },
407    #[error("Internal Error")]
408    InternalError,
409}
410
411pub type RequestResult<T> = std::result::Result<T, RequestError>;
412
413impl IntoResponse for RequestError {
414    fn into_response(self) -> Response {
415        debug!(target: LOG_UI, err=%self, "Request Error");
416
417        let (status_code, message) = match self {
418            Self::BadRequest { source } => {
419                (StatusCode::BAD_REQUEST, format!("Bad Request: {source}"))
420            }
421            _ => (
422                StatusCode::INTERNAL_SERVER_ERROR,
423                "Internal Service Error".to_owned(),
424            ),
425        };
426
427        (status_code, AppJson(UserErrorResponse { message })).into_response()
428    }
429}
430
431// How we want user errors responses to be serialized
432#[derive(Serialize)]
433pub struct UserErrorResponse {
434    pub message: String,
435}