fedimint_server_ui/
meta.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2
3use axum::extract::{Form, State};
4use axum::response::{Html, IntoResponse, Redirect, Response};
5use axum_extra::extract::cookie::CookieJar;
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 tracing::warn;
12
13use crate::error::{RequestError, RequestResult};
14use crate::{AuthState, LOG_UI, check_auth};
15
16// Function to render the Meta module UI section
17pub async fn render(meta: &Meta) -> Markup {
18    // Get current consensus value
19    let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
20    // Get current revision number
21    let revision = meta
22        .handle_get_consensus_revision_request_ui()
23        .await
24        .ok()
25        .unwrap_or(0);
26    // Get current submissions from all peers
27    let submissions = meta
28        .handle_get_submissions_request_ui()
29        .await
30        .ok()
31        .unwrap_or_default();
32
33    let current_meta_keys = if let Some(o) = submissions
34        .get(&meta.our_peer_id)
35        .cloned()
36        .or_else(|| consensus_value.clone())
37        .and_then(|v| v.as_object().cloned())
38    {
39        o
40    } else {
41        serde_json::Map::new()
42    };
43
44    html! {
45        // Meta Configuration Card
46        div class="row gy-4 mt-2" {
47            div class="col-12" {
48                div class="card h-100" {
49                    div class="card-header dashboard-header" { "Meta Configuration" }
50                    div class="card-body" {
51                        // Current Consensus Section
52                        div class="mb-4" {
53                            h5 { "Current Consensus (Revision: " (revision) ")" }
54                            @if let Some(value) = &consensus_value {
55                                pre class="bg-light p-3 user-select-all" {
56                                    code {
57                                        (serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string()))
58                                    }
59                                }
60                            } @else {
61                                div class="alert alert-secondary" { "No consensus value has been established yet." }
62                            }
63                        }
64
65                        // Submission Form
66                        div class="mb-4" {
67                            (render_meta_edit_form(current_meta_keys, false, MetaEditForm::default()))
68                        }
69
70                        // Current Submissions Section
71                        (render_submissions_form(meta.our_peer_id, &submissions))
72                    }
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"
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<AuthState<DynDashboardApi>>,
173    jar: CookieJar,
174    Form(form): Form<MetaEditForm>,
175) -> RequestResult<Response> {
176    // Check authentication
177    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
178        return Ok(Redirect::to("/login").into_response());
179    }
180
181    let meta_module = state.api.get_module::<Meta>().unwrap();
182
183    let top_level_keys = form.top_level_keys()?;
184    let top_level_object = Value::Object(top_level_keys.clone());
185
186    meta_module
187        .handle_submit_request_ui(top_level_object.clone())
188        .await
189        .inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
190        .map_err(|_err| RequestError::InternalError)?;
191
192    let mut submissions = meta_module
193        .handle_get_submissions_request_ui()
194        .await
195        .ok()
196        .unwrap_or_default();
197
198    submissions.insert(meta_module.our_peer_id, top_level_object);
199
200    let content = html! {
201        (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
202
203        // Re-render submission with our submission added, as it will take couple of milliseconds
204        // for it to get processed and it's confusing if it doesn't immediatel show up.
205        (render_submissions_form(meta_module.our_peer_id, &submissions))
206    };
207    Ok(Html(content.into_string()).into_response())
208}
209
210pub async fn post_reset(
211    State(state): State<AuthState<DynDashboardApi>>,
212    jar: CookieJar,
213    Form(_form): Form<MetaEditForm>,
214) -> RequestResult<Response> {
215    // Check authentication
216    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
217        return Ok(Redirect::to("/login").into_response());
218    }
219
220    let meta_module = state.api.get_module::<Meta>().unwrap();
221
222    let consensus_value = meta_module
223        .handle_get_consensus_request_ui()
224        .await
225        .ok()
226        .flatten();
227
228    let top_level_keys = if let Some(serde_json::Value::Object(o)) = consensus_value {
229        o
230    } else {
231        serde_json::Map::new()
232    };
233    let top_level_object = Value::Object(top_level_keys.clone());
234
235    meta_module
236        .handle_submit_request_ui(top_level_object.clone())
237        .await
238        .inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
239        .map_err(|_err| RequestError::InternalError)?;
240
241    let mut submissions = meta_module
242        .handle_get_submissions_request_ui()
243        .await
244        .ok()
245        .unwrap_or_default();
246
247    submissions.remove(&meta_module.our_peer_id);
248
249    let content = html! {
250        (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
251
252        // Re-render submission with our submission added, as it will take couple of milliseconds
253        // for it to get processed and it's confusing if it doesn't immediatel show up.
254        (render_submissions_form(meta_module.our_peer_id, &submissions))
255    };
256    Ok(Html(content.into_string()).into_response())
257}
258
259pub async fn post_set(
260    State(state): State<AuthState<DynDashboardApi>>,
261    jar: CookieJar,
262    Form(mut form): Form<MetaEditForm>,
263) -> RequestResult<Response> {
264    // Check authentication
265    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
266        return Ok(Redirect::to("/login").into_response());
267    }
268
269    let mut top_level_object = form.top_level_keys()?;
270
271    let key = form.add_key.trim();
272    let value = form.add_value.trim();
273    let value = serde_json::from_str(value)
274        .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
275
276    top_level_object.insert(key.to_string(), value);
277
278    form.add_key = "".into();
279    form.add_value = "".into();
280    let content = render_meta_edit_form(top_level_object, true, MetaEditForm::default());
281    Ok(Html(content.into_string()).into_response())
282}
283
284pub async fn post_delete(
285    State(state): State<AuthState<DynDashboardApi>>,
286    jar: CookieJar,
287    Form(mut form): Form<MetaEditForm>,
288) -> RequestResult<Response> {
289    // Check authentication
290    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
291        return Ok(Redirect::to("/login").into_response());
292    }
293
294    let mut top_level_json = form.top_level_keys()?;
295
296    let key = form.delete_key.trim();
297
298    top_level_json.remove(key);
299    form.delete_key = "".into();
300
301    let content = render_meta_edit_form(top_level_json, true, form);
302    Ok(Html(content.into_string()).into_response())
303}
304
305// <https://fedibtc.github.io/fedi-docs/docs/fedi/meta_fields/federation-metadata-configurations>
306const WELL_KNOWN_KEYS: &[&str] = &[
307    "welcome_message",
308    "fedi:pinned_message",
309    "fedi:federation_icon_url",
310    "fedi:tos_url",
311    "fedi:default_currency",
312    "fedi:popup_end_timestamp",
313    "fedi:invite_codes_disabled",
314    "fedi:new_members_disabled",
315    "fedi:max_invoice_msats",
316    "fedi:max_balance_msats",
317    "fedi:max_stable_balance_msats",
318    "fedi:fedimods",
319    "fedi:default_group_chats",
320    "fedi:offline_wallet_disabled",
321];
322
323pub fn render_meta_edit_form(
324    mut top_level_json: serde_json::Map<String, Value>,
325    // was the value edited via set/delete
326    pending: bool,
327    form: MetaEditForm,
328) -> Markup {
329    top_level_json.sort_keys();
330
331    let known_keys: HashSet<String> = top_level_json
332        .keys()
333        .cloned()
334        .chain(WELL_KNOWN_KEYS.iter().map(ToString::to_string))
335        .collect();
336    html! {
337        form #meta-edit-form hx-swap-oob=(true) {
338            h5 {
339                "Proposal"
340                @if pending {
341                    " (Pending)"
342                }
343            }
344            div class="input-group mb-2" {
345                textarea class="form-control" rows="15" readonly
346                    name="json_content"
347                {
348                    (serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
349                }
350            }
351            div class="input-group mb-2" {
352                input #add-key  type="text" class="form-control" placeholder="Key" aria-label="Key" list="keyOptions"
353                    // keys are usually shorter than values, so keep it small
354                    style="max-width: 250px;"
355                    name="add_key"
356                    value=(form.add_key)
357                {}
358                span class="input-group-text" { ":" }
359                input #add-value type="text" name="add_value" class="form-control" placeholder="Value" aria-label="Value"
360                    value=(form.add_value)
361                {}
362
363                datalist id="keyOptions" {
364                    @for key in known_keys {
365                        option value=(key) {}
366                    }
367                }
368
369                button class="btn btn-primary btn-min-width"
370                    type="button" id="button-set"
371                    title="Set a value in a meta proposal"
372                    hx-post="/meta/set"
373                    hx-swap="none"
374                    hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
375                { "Set" }
376            }
377            div class="input-group mb-2" {
378                select class="form-select"
379                    id="delete-key"
380                    name="delete_key"
381                {
382                    option value="" {}
383                    @for key in top_level_json.keys() {
384                        option value=(key) selected[key == &form.delete_key]{ (key) }
385                    }
386                }
387                button class="btn btn-primary btn-min-width"
388                    hx-post="/meta/delete"
389                    hx-swap="none"
390                    hx-trigger="click, keypress[key=='Enter'] from:#delete-key"
391                    title="Delete a value in a meta proposal"
392                { "Delete" }
393            }
394            div class="d-flex justify-content-between btn-min-width" {
395                button class="btn btn-outline-warning me-5"
396                    title="Reset to current consensus"
397                    hx-post="/meta/reset"
398                    hx-swap="none"
399                { "Reset" }
400                button class="btn btn-success btn-min-width"
401                    hx-post="/meta/submit"
402                    hx-swap="none"
403                    title="Submit new meta document for approval of other peers"
404                { "Submit" }
405            }
406        }
407    }
408}