use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::LazyLock;
use axum::extract::{Form, FromRequest, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use chrono::NaiveDateTime;
use fedimint_core::PeerId;
use fedimint_core::module::serde_json::{self, Value};
use fedimint_meta_server::Meta;
use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
use fedimint_ui_common::UiState;
use fedimint_ui_common::auth::UserAuth;
use maud::{Markup, html};
use serde::Serialize;
use thiserror::Error;
use tracing::{debug, warn};
use crate::LOG_UI;
pub const META_SUBMIT_ROUTE: &str = "/meta/submit";
pub const META_SET_ROUTE: &str = "/meta/set";
pub const META_RESET_ROUTE: &str = "/meta/reset";
pub const META_DELETE_ROUTE: &str = "/meta/delete";
pub const META_VALUE_INPUT_ROUTE: &str = "/meta/value-input";
pub const META_MERGE_ROUTE: &str = "/meta/merge";
enum KeyType {
String,
Url,
Amount,
DateTime,
Json,
}
struct KeySchema {
description: &'static str,
value_type: KeyType,
}
static WELL_KNOWN_KEYS: LazyLock<BTreeMap<&'static str, KeySchema>> = LazyLock::new(|| {
BTreeMap::from([
(
"welcome_message",
KeySchema {
description: "A welcome message for new users joining the federation",
value_type: KeyType::String,
},
),
(
"federation_expiry_timestamp",
KeySchema {
description: "The date and time after which the federation will shut down",
value_type: KeyType::DateTime,
},
),
(
"federation_name",
KeySchema {
description: "The human-readable name of the federation",
value_type: KeyType::String,
},
),
(
"federation_successor",
KeySchema {
description: "An invite code to a successor federation for user migration",
value_type: KeyType::String,
},
),
(
"meta_override_url",
KeySchema {
description: "A URL to a file containing overrides for meta fields",
value_type: KeyType::Url,
},
),
(
"vetted_gateways",
KeySchema {
description: "A list of gateway identifiers vetted by the federation",
value_type: KeyType::Json,
},
),
(
"recurringd_api",
KeySchema {
description: "The API URL of a recurringd instance for creating LNURLs",
value_type: KeyType::Url,
},
),
(
"lnaddress_api",
KeySchema {
description: "The API URL of a Lightning Address Server for serving LNURLs",
value_type: KeyType::Url,
},
),
(
"fedi:pinned_message",
KeySchema {
description: "",
value_type: KeyType::String,
},
),
(
"fedi:federation_icon_url",
KeySchema {
description: "",
value_type: KeyType::Url,
},
),
(
"fedi:tos_url",
KeySchema {
description: "",
value_type: KeyType::Url,
},
),
(
"fedi:default_currency",
KeySchema {
description: "",
value_type: KeyType::String,
},
),
(
"fedi:invite_codes_disabled",
KeySchema {
description: "",
value_type: KeyType::String,
},
),
(
"fedi:new_members_disabled",
KeySchema {
description: "",
value_type: KeyType::String,
},
),
(
"fedi:max_invoice_msats",
KeySchema {
description: "",
value_type: KeyType::Amount,
},
),
(
"fedi:max_balance_msats",
KeySchema {
description: "",
value_type: KeyType::Amount,
},
),
(
"fedi:max_stable_balance_msats",
KeySchema {
description: "",
value_type: KeyType::Amount,
},
),
(
"fedi:fedimods",
KeySchema {
description: "",
value_type: KeyType::Json,
},
),
(
"fedi:default_group_chats",
KeySchema {
description: "",
value_type: KeyType::Json,
},
),
(
"fedi:offline_wallet_disabled",
KeySchema {
description: "",
value_type: KeyType::String,
},
),
])
});
pub async fn render(meta: &Meta) -> Markup {
let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
let revision = meta
.handle_get_consensus_revision_request_ui()
.await
.ok()
.unwrap_or(0);
let submissions = meta
.handle_get_submissions_request_ui()
.await
.ok()
.unwrap_or_default();
let consensus_map = consensus_value
.as_ref()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
let current_meta_keys = if let Some(o) = submissions
.get(&meta.our_peer_id)
.cloned()
.or_else(|| consensus_value.clone())
.and_then(|v| v.as_object().cloned())
{
o
} else {
serde_json::Map::new()
};
html! {
div class="card h-100" {
div class="card-header dashboard-header" { "Meta Configuration" }
div class="card-body" {
div class="mb-4" {
h5 { "Current Consensus (Revision: " (revision) ")" }
@if consensus_value.is_some() {
div class="row mb-2" {
div class="col-md-6" {
strong { "Full document" }
pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
code {
(serde_json::to_string_pretty(&consensus_map).unwrap_or_else(|_| "Invalid JSON".to_string()))
}
}
}
div class="col-md-6" {
(render_consensus_summary(&consensus_map))
}
}
} @else {
div class="alert alert-secondary" { "No consensus value has been established yet." }
}
div class="mb-4" {
(render_meta_edit_form(&consensus_map, current_meta_keys, false, MetaEditForm::default()))
}
(render_submissions_form(meta.our_peer_id, &consensus_map, &submissions))
}
}
}
}
}
enum MetaChange {
Set { key: String, value: String },
Deleted { key: String },
}
fn compute_changes(
consensus: &serde_json::Map<String, Value>,
proposal: &serde_json::Map<String, Value>,
) -> Vec<MetaChange> {
let mut changes = Vec::new();
for (key, new_val) in proposal {
let changed = consensus.get(key) != Some(new_val);
if changed {
changes.push(MetaChange::Set {
key: key.clone(),
value: format_value_for_display(key, new_val),
});
}
}
for key in consensus.keys() {
if !proposal.contains_key(key) {
changes.push(MetaChange::Deleted { key: key.clone() });
}
}
changes
}
fn format_value_for_display(key: &str, value: &Value) -> String {
if let Some(schema) = WELL_KNOWN_KEYS.get(key) {
match schema.value_type {
KeyType::DateTime => {
if let Some(ts) = value
.as_str()
.and_then(|s| s.parse::<i64>().ok())
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
{
return ts.format("%Y-%m-%d %H:%M UTC").to_string();
}
}
KeyType::Amount => {
if let Some(s) = value.as_str() {
return format!("{s} msats");
}
}
KeyType::Json => {
return serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
}
KeyType::Url | KeyType::String => {}
}
}
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn render_changes_summary(changes: &[MetaChange]) -> Markup {
html! {
strong { "Proposed changes" }
@if changes.is_empty() {
p class="text-muted" { "No changes" }
} @else {
ul class="mb-0 ps-3" {
@for change in changes {
li {
@match change {
MetaChange::Set { key, value } => {
strong { (key) }
" set to "
em { (value) }
},
MetaChange::Deleted { key } => {
strong { (key) }
" deleted"
},
}
}
}
}
}
}
}
fn render_consensus_summary(map: &serde_json::Map<String, Value>) -> Markup {
html! {
@if map.is_empty() {
span class="text-muted" { "No fields set" }
} @else {
strong { "Summary" }
ul class="mb-0 ps-3" {
@for (key, value) in map {
li {
strong { (key) }
" = "
em { (format_value_for_display(key, value)) }
}
}
}
}
}
}
fn render_submissions_form(
our_id: PeerId,
consensus: &serde_json::Map<String, Value>,
submissions: &BTreeMap<PeerId, Value>,
) -> Markup {
let mut submissions_by_value: HashMap<
String,
(BTreeSet<PeerId>, serde_json::Map<String, Value>),
> = HashMap::new();
for (peer_id, value) in submissions {
let value_str =
serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string());
let proposal_map = value.as_object().cloned().unwrap_or_default();
let entry = submissions_by_value
.entry(value_str)
.or_insert_with(|| (BTreeSet::new(), proposal_map));
entry.0.insert(*peer_id);
}
html! {
div #meta-submissions hx-swap-oob=(true) {
@if !submissions.is_empty() {
h5 { "Current Proposals" }
@for (value_str, (peer_ids, proposal_map)) in &submissions_by_value {
div class="card mb-3" {
div class="card-header py-2" {
strong { "Peers: " }
(peer_ids.iter()
.map(|n| n.to_string())
.collect::<Vec<String>>()
.join(", "))
}
div class="card-body py-2" {
div class="row" {
div class="col-md-6" {
strong { "Full proposal" }
pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
code { (value_str) }
}
}
div class="col-md-6" {
(render_changes_summary(&compute_changes(consensus, proposal_map)))
}
}
}
@if !peer_ids.contains(&our_id) {
div class="card-footer py-2 d-flex gap-2 justify-content-end" {
form method="post"
hx-post=(META_SUBMIT_ROUTE)
hx-swap="none"
{
input type="hidden" name="json_content"
value=(value_str);
button type="submit" class="btn btn-sm btn-success" {
"Accept As-Is"
}
}
form method="post"
hx-post=(META_MERGE_ROUTE)
hx-swap="none"
hx-include="#meta-edit-form [name='json_content']"
{
input type="hidden" name="proposal_json"
value=(value_str);
button type="submit" class="btn btn-sm btn-primary" {
"Add Changes To My Proposal"
}
}
}
}
}
}
}
}
}
}
#[derive(serde::Deserialize, Default)]
pub struct MetaEditForm {
pub json_content: String,
#[serde(default)]
pub add_key: String,
#[serde(default)]
pub add_value: String,
#[serde(default)]
pub delete_key: String,
}
impl MetaEditForm {
fn top_level_keys(&self) -> RequestResult<serde_json::Map<String, Value>> {
Ok(
if let Some(serde_json::Value::Object(o)) =
serde_json::from_slice(self.json_content.as_bytes())
.map_err(|x| RequestError::BadRequest { source: x.into() })?
{
o
} else {
serde_json::Map::new()
},
)
}
}
async fn get_consensus_map(meta: &Meta) -> serde_json::Map<String, Value> {
meta.handle_get_consensus_request_ui()
.await
.ok()
.flatten()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default()
}
pub async fn post_submit(
State(state): State<UiState<DynDashboardApi>>,
_auth: UserAuth,
Form(form): Form<MetaEditForm>,
) -> RequestResult<Response> {
let meta_module = state.api.get_module::<Meta>().unwrap();
let top_level_keys = form.top_level_keys()?;
let top_level_object = Value::Object(top_level_keys.clone());
meta_module
.handle_submit_request_ui(top_level_object.clone())
.await
.inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
.map_err(|_err| RequestError::InternalError)?;
let consensus_map = get_consensus_map(meta_module).await;
let mut submissions = meta_module
.handle_get_submissions_request_ui()
.await
.ok()
.unwrap_or_default();
submissions.insert(meta_module.our_peer_id, top_level_object);
let content = html! {
(render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
(render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
};
Ok(Html(content.into_string()).into_response())
}
pub async fn post_reset(
State(state): State<UiState<DynDashboardApi>>,
_auth: UserAuth,
Form(_form): Form<MetaEditForm>,
) -> RequestResult<Response> {
let meta_module = state.api.get_module::<Meta>().unwrap();
let consensus_map = get_consensus_map(meta_module).await;
let top_level_keys = consensus_map.clone();
let top_level_object = Value::Object(top_level_keys.clone());
meta_module
.handle_submit_request_ui(top_level_object.clone())
.await
.inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
.map_err(|_err| RequestError::InternalError)?;
let mut submissions = meta_module
.handle_get_submissions_request_ui()
.await
.ok()
.unwrap_or_default();
submissions.remove(&meta_module.our_peer_id);
let content = html! {
(render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
(render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
};
Ok(Html(content.into_string()).into_response())
}
pub async fn post_set(
State(state): State<UiState<DynDashboardApi>>,
_auth: UserAuth,
Form(mut form): Form<MetaEditForm>,
) -> RequestResult<Response> {
let meta_module = state.api.get_module::<Meta>().unwrap();
let consensus_map = get_consensus_map(meta_module).await;
let mut top_level_object = form.top_level_keys()?;
let key = form.add_key.trim();
let raw_value = form.add_value.trim();
let key_type = WELL_KNOWN_KEYS
.get(key)
.map(|s| &s.value_type)
.unwrap_or(&KeyType::String);
let value = convert_input_value(raw_value, key_type)?;
top_level_object.insert(key.to_string(), value);
form.add_key = "".into();
form.add_value = "".into();
let content = render_meta_edit_form(
&consensus_map,
top_level_object,
true,
MetaEditForm::default(),
);
Ok(Html(content.into_string()).into_response())
}
pub async fn post_delete(
State(state): State<UiState<DynDashboardApi>>,
_auth: UserAuth,
Form(mut form): Form<MetaEditForm>,
) -> RequestResult<Response> {
let meta_module = state.api.get_module::<Meta>().unwrap();
let consensus_map = get_consensus_map(meta_module).await;
let mut top_level_json = form.top_level_keys()?;
let key = form.delete_key.trim();
top_level_json.remove(key);
form.delete_key = "".into();
let content = render_meta_edit_form(&consensus_map, top_level_json, true, form);
Ok(Html(content.into_string()).into_response())
}
#[derive(serde::Deserialize)]
pub struct MetaMergeForm {
pub json_content: String,
pub proposal_json: String,
}
pub async fn post_merge(
State(state): State<UiState<DynDashboardApi>>,
_auth: UserAuth,
Form(form): Form<MetaMergeForm>,
) -> RequestResult<Response> {
let meta_module = state.api.get_module::<Meta>().unwrap();
let consensus_map = get_consensus_map(meta_module).await;
let mut current: serde_json::Map<String, Value> =
if let Ok(Value::Object(o)) = serde_json::from_str(&form.json_content) {
o
} else {
serde_json::Map::new()
};
let proposal: serde_json::Map<String, Value> =
if let Ok(Value::Object(o)) = serde_json::from_str(&form.proposal_json) {
o
} else {
serde_json::Map::new()
};
for change in &compute_changes(&consensus_map, &proposal) {
match change {
MetaChange::Set { key, .. } => {
if let Some(val) = proposal.get(key) {
current.insert(key.clone(), val.clone());
}
}
MetaChange::Deleted { key } => {
current.remove(key);
}
}
}
let content = render_meta_edit_form(&consensus_map, current, true, MetaEditForm::default());
Ok(Html(content.into_string()).into_response())
}
fn render_value_input(key_type: &KeyType, current_value: &str) -> Markup {
match key_type {
KeyType::Url => html! {
input #add-value type="url" name="add_value" class="form-control"
placeholder="https://..." aria-label="Value"
value=(current_value) {}
},
KeyType::Amount => html! {
input #add-value type="number" name="add_value" class="form-control"
placeholder="Amount (msats)" aria-label="Value"
value=(current_value) {}
},
KeyType::DateTime => {
let val = if current_value.is_empty() {
chrono::Utc::now().format("%Y-%m-%dT00:00").to_string()
} else {
current_value.to_string()
};
html! {
input #add-value type="datetime-local" name="add_value" class="form-control"
aria-label="Value" value=(val) {}
}
}
KeyType::Json => html! {
input #add-value type="text" name="add_value" class="form-control"
placeholder="{}" aria-label="Value"
value=(current_value) {}
},
KeyType::String => html! {
input #add-value type="text" name="add_value" class="form-control"
placeholder="Value" aria-label="Value"
value=(current_value) {}
},
}
}
fn render_value_description(key: &str) -> Markup {
let schema = WELL_KNOWN_KEYS.get(key);
let description = schema.map(|s| s.description).unwrap_or("");
let type_hint = match schema.map(|s| &s.value_type) {
Some(KeyType::DateTime) => " (UTC)",
Some(KeyType::Amount) => " (msats)",
_ => "",
};
html! {
@if !description.is_empty() || !type_hint.is_empty() {
small class="form-text text-muted" { (description) (type_hint) }
}
}
}
fn render_key_picker(extra_keys: &BTreeSet<String>) -> Markup {
let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
.keys()
.copied()
.chain(extra_keys.iter().map(|s| s.as_str()))
.collect();
html! {
select class="form-select"
style="flex: 0 0 auto; width: 2em; padding-left: 0.2em;"
aria-label="Pick a well-known key"
onchange="if(this.value){var k=document.getElementById('add-key');k.value=this.value;this.value='';htmx.trigger(k,'change')}"
{
option value="" selected {}
@for key in &all_keys {
option value=(key) { (key) }
}
}
}
}
fn render_set_button(key_in_proposal: bool, oob: bool) -> Markup {
let class = if key_in_proposal {
"btn btn-primary btn-min-width"
} else {
"btn btn-primary btn-min-width rounded-end"
};
html! {
button #button-set class=(class) type="button"
hx-swap-oob=[oob.then_some("outerHTML")]
title="Set a value in a meta proposal"
hx-post=(META_SET_ROUTE)
hx-swap="none"
hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
{ "Set" }
}
}
fn render_delete_button(key: &str, visible: bool, oob: bool) -> Markup {
if visible {
html! {
button #button-delete class="btn btn-danger btn-min-width" type="button"
hx-swap-oob=[oob.then_some("outerHTML")]
title="Delete this key from the proposal"
hx-post=(META_DELETE_ROUTE)
hx-swap="none"
hx-vals=(format!(r#"{{"delete_key":"{}"}}"#, key))
{ "Delete" }
}
} else {
html! {
span #button-delete style="display:none"
hx-swap-oob=[oob.then_some("outerHTML")]
{}
}
}
}
fn parse_proposal(json_content: &str) -> serde_json::Map<String, Value> {
serde_json::from_str(json_content)
.ok()
.and_then(|v: Value| v.as_object().cloned())
.unwrap_or_default()
}
#[derive(serde::Deserialize)]
pub struct ValueInputQuery {
#[serde(default)]
pub add_key: String,
#[serde(default)]
pub json_content: String,
}
pub async fn get_value_input(
_auth: UserAuth,
Query(query): Query<ValueInputQuery>,
) -> impl IntoResponse {
let key = query.add_key.trim();
let proposal = parse_proposal(&query.json_content);
let key_in_proposal = !key.is_empty() && proposal.contains_key(key);
let key_type = WELL_KNOWN_KEYS
.get(key)
.map(|s| &s.value_type)
.unwrap_or(&KeyType::String);
let content = html! {
(render_value_input(key_type, ""))
div #value-description-container hx-swap-oob="innerHTML" {
(render_value_description(key))
}
(render_set_button(key_in_proposal, true))
(render_delete_button(key, key_in_proposal, true))
};
Html(content.into_string())
}
fn convert_input_value(raw: &str, key_type: &KeyType) -> RequestResult<Value> {
match key_type {
KeyType::DateTime => {
let dt = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
.map_err(|e| RequestError::BadRequest {
source: anyhow::anyhow!("Invalid datetime: {e}"),
})?;
Ok(Value::String(dt.and_utc().timestamp().to_string()))
}
KeyType::Amount => {
let _: u64 = raw.parse().map_err(|e| RequestError::BadRequest {
source: anyhow::anyhow!("Invalid amount: {e}"),
})?;
Ok(Value::String(raw.to_string()))
}
KeyType::Json => serde_json::from_str(raw).map_err(|e| RequestError::BadRequest {
source: anyhow::anyhow!("Invalid JSON: {e}"),
}),
KeyType::Url | KeyType::String => {
Ok(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
}
}
}
pub fn render_meta_edit_form(
consensus: &serde_json::Map<String, Value>,
mut top_level_json: serde_json::Map<String, Value>,
pending: bool,
form: MetaEditForm,
) -> Markup {
top_level_json.sort_keys();
let changes = compute_changes(consensus, &top_level_json);
let extra_keys: BTreeSet<String> = top_level_json.keys().cloned().collect();
let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
.keys()
.copied()
.chain(extra_keys.iter().map(|s| s.as_str()))
.collect();
let default_input = render_value_input(&KeyType::String, &form.add_value);
html! {
form #meta-edit-form hx-swap-oob=(true) {
h5 {
"Propose Changes"
@if pending {
" (Pending - click Submit to Propose)"
}
}
input type="hidden" name="json_content"
value=(serde_json::to_string_pretty(&top_level_json).expect("Can't fail")) {}
div class="row mb-2" {
div class="col-md-6" {
strong { "Full proposal" }
pre class="m-0 p-2 bg-light" style="min-height: 120px; max-height: 40vh; overflow-y: auto;" {
code {
(serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
}
}
}
div class="col-md-6" {
(render_changes_summary(&changes))
}
}
datalist #keyOptions {
@for key in &all_keys {
option value=(key) {}
}
}
div class="input-group mb-1" {
(render_key_picker(&extra_keys))
input #add-key type="text" list="keyOptions" name="add_key" class="form-control"
style="max-width: 250px;" placeholder="Key"
hx-get=(META_VALUE_INPUT_ROUTE)
hx-trigger="change, input changed delay:300ms"
hx-target="#add-value"
hx-swap="outerHTML"
hx-include="#meta-edit-form [name='json_content']"
{}
span class="input-group-text" { ":" }
(default_input)
(render_set_button(false, false))
(render_delete_button("", false, false))
}
div #value-description-container {}
div class="d-flex justify-content-between btn-min-width" {
button type="button" class="btn btn-outline-warning me-5"
title="Reset to current consensus"
hx-post=(META_RESET_ROUTE)
hx-swap="none"
hx-confirm="This will clear all the changes in your proposal. Are you sure?"
{ "Reset" }
button type="button" class="btn btn-success btn-min-width"
hx-post=(META_SUBMIT_ROUTE)
hx-swap="none"
title="Submit new meta document for approval of other peers"
{ "Submit" }
}
}
}
}
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(RequestError))]
struct AppJson<T>(pub T);
impl<T> IntoResponse for AppJson<T>
where
axum::Json<T>: IntoResponse,
{
fn into_response(self) -> Response {
axum::Json(self.0).into_response()
}
}
#[derive(Debug, Error)]
pub enum RequestError {
#[error("Bad request: {source}")]
BadRequest { source: anyhow::Error },
#[error("Internal Error")]
InternalError,
}
pub type RequestResult<T> = std::result::Result<T, RequestError>;
impl IntoResponse for RequestError {
fn into_response(self) -> Response {
debug!(target: LOG_UI, err=%self, "Request Error");
let (status_code, message) = match self {
Self::BadRequest { source } => {
(StatusCode::BAD_REQUEST, format!("Bad Request: {source}"))
}
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Service Error".to_owned(),
),
};
(status_code, AppJson(UserErrorResponse { message })).into_response()
}
}
#[derive(Serialize)]
pub struct UserErrorResponse {
pub message: String,
}