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
16pub async fn render(meta: &Meta) -> Markup {
18 let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
20 let revision = meta
22 .handle_get_consensus_revision_request_ui()
23 .await
24 .ok()
25 .unwrap_or(0);
26 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 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 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 div class="mb-4" {
67 (render_meta_edit_form(current_meta_keys, false, MetaEditForm::default()))
68 }
69
70 (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#[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 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 (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 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 (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 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 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
305const 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 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 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}