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