config.rs (10532B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 use std::{collections::BTreeMap, path::PathBuf}; 21 22 use compact_str::CompactString; 23 use jiff::Span; 24 use serde::{Deserialize, Serialize}; 25 use taler_api::{ 26 Serve, 27 config::DbCfg, 28 error::{ApiResult, failure}, 29 }; 30 use taler_common::{ 31 config::{CfgErr, Config}, 32 error_code::ErrorCode, 33 map_config, 34 types::amount::{Amount, Currency}, 35 }; 36 use taler_macros::EnumMeta; 37 use tracing::warn; 38 use url::Url; 39 use uuid::Uuid; 40 41 use crate::{TanChannel, payto::PaytoCtx, pw::PwCrypto}; 42 43 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 44 pub struct CurrencySpecification { 45 pub name: CompactString, 46 pub num_fractional_input_digits: u64, 47 pub num_fractional_normal_digits: u64, 48 pub num_fractional_trailing_zero_digits: u64, 49 pub alt_unit_names: BTreeMap<CompactString, CompactString>, 50 } 51 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] 53 #[enum_meta(Str)] 54 #[allow(non_camel_case_types)] 55 pub enum WireMethod { 56 iban, 57 #[enum_meta(rename = "x-taler-bank")] 58 x_taler_bank, 59 } 60 61 /** Configuration for libeufin-bank */ 62 pub struct BankCfg { 63 pub cfg: Config, 64 pub name: CompactString, 65 pub base_url: Url, 66 pub regional_currency: Currency, 67 pub regional_currency_spec: CurrencySpecification, 68 pub wire_transfer_fees: Amount, 69 pub min_amount: Amount, 70 pub max_amount: Amount, 71 pub allow_registration: bool, 72 pub allow_account_deletion: bool, 73 pub allow_edit_name: bool, 74 pub allow_edit_cashout: bool, 75 pub default_debt_limit: Amount, 76 pub registration_bonus: Amount, 77 pub suggested_withdrawal_exchange: Option<String>, 78 pub fiat: Option<(Currency, CurrencySpecification)>, 79 pub spa_path: Option<String>, 80 pub fallback_spa: PathBuf, 81 pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>, 82 pub ctx: PaytoCtx, 83 pub wire_method: WireMethod, 84 pub pw_crypto: PwCrypto, 85 pub gc_abort_after: Span, 86 pub gc_clean_after: Span, 87 pub gc_delete_after: Span, 88 pub pwd_check_quality: bool, 89 pub basic_auth_compat: bool, 90 pub db_cfg: DbCfg, 91 pub serve: Serve, 92 } 93 94 impl BankCfg { 95 fn currency_specification( 96 cfg: &Config, 97 currency: &Currency, 98 ) -> Result<CurrencySpecification, CfgErr> { 99 for s in cfg.sections() { 100 if s.name.starts_with("currency-") 101 && s.currency("code").require()? == *currency 102 && s.boolean("enabled").require()? 103 { 104 return Ok(CurrencySpecification { 105 name: s.cstr("name").require()?, 106 num_fractional_input_digits: s.number("fractional_input_digits").require()?, 107 num_fractional_normal_digits: s.number("fractional_normal_digits").require()?, 108 num_fractional_trailing_zero_digits: s 109 .number("fractional_trailing_zero_digits") 110 .require()?, 111 alt_unit_names: s.json("alt_unit_names").require()?, 112 }); 113 } 114 } 115 warn!(target: "config", "Missing currency specification for {currency}, using sane defaults"); 116 Ok(CurrencySpecification { 117 name: currency.as_ref().into(), 118 num_fractional_input_digits: 2, 119 num_fractional_normal_digits: 2, 120 num_fractional_trailing_zero_digits: 2, 121 alt_unit_names: [("0".into(), currency.as_ref().into())].into(), 122 }) 123 } 124 125 pub fn parse(cfg: &Config) -> Result<Self, CfgErr> { 126 let s = cfg.section("libeufin-bank"); 127 let base_url = s.base_url("base_url").require()?; 128 let hostname = base_url.host_str().unwrap_or_default().into(); 129 130 let wire_method = s 131 .parse("payment target type", "wire_type") 132 .default(WireMethod::iban)?; 133 134 let currency = s.currency("currency").require()?; 135 let allow_conversion = s.boolean("allow_conversion").default(false)?; 136 let fiat = if allow_conversion { 137 let currency = s.currency("fiat_currency").require()?; 138 let spec = Self::currency_specification(cfg, ¤cy)?; 139 Some((currency, spec)) 140 } else { 141 None 142 }; 143 let zero = Amount::zero(¤cy); 144 let max = Amount::max(¤cy); 145 let mut tan_channels = BTreeMap::new(); 146 for channel in TanChannel::entries { 147 if let Some(path) = s.path(&format!("tan_{channel}")).opt()? { 148 tan_channels.insert( 149 *channel, 150 ( 151 path, 152 s.json(&format!("tan_{channel}_env")) 153 .default(BTreeMap::default())?, 154 ), 155 ); 156 } 157 } 158 Ok(BankCfg { 159 name: s 160 .cstr("name") 161 .default(CompactString::const_new("Taler Bank"))?, 162 base_url, 163 regional_currency: currency, 164 regional_currency_spec: Self::currency_specification(cfg, ¤cy)?, 165 wire_transfer_fees: s.amount("wire_transfer_fees", ¤cy).default(zero)?, 166 min_amount: s 167 .amount("min_wire_transfer_amount", ¤cy) 168 .default(zero)?, 169 max_amount: s 170 .amount("max_wire_transfer_amount", ¤cy) 171 .default(max)?, 172 allow_registration: s.boolean("allow_registration").default(false)?, 173 allow_account_deletion: s.boolean("allow_account_deletion").default(false)?, 174 allow_edit_name: s.boolean("allow_edit_name").default(false)?, 175 allow_edit_cashout: s.boolean("allow_edit_cashout_payto_uri").default(false)?, 176 default_debt_limit: s.amount("default_debt_limit", ¤cy).default(zero)?, 177 registration_bonus: s.amount("registration_bonus", ¤cy).default(zero)?, 178 suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?, 179 fiat, 180 spa_path: s.path("spa").opt()?, 181 fallback_spa: PathBuf::from(cfg.section("paths").path("datadir").require()?) 182 .join("spa"), 183 tan_channels, 184 ctx: PaytoCtx { 185 bic: s.parse("bic", "iban_payto_bic").opt()?, 186 hostname, 187 }, 188 wire_method, 189 pw_crypto: map_config!(s, "password hash algorithm", "pwd_hash_algorithm", 190 "bcrypt" => { 191 s.json::<BcryptCfg>("pwd_hash_config").require()?.into() 192 } 193 ) 194 .require()?, 195 gc_abort_after: s.span("gc_abort_after").require()?, 196 gc_clean_after: s.span("gc_clean_after").require()?, 197 gc_delete_after: s.span("gc_delete_after").require()?, 198 pwd_check_quality: s.boolean("pwd_check").require()?, 199 basic_auth_compat: s.boolean("pwd_auth_compat").require()?, 200 serve: Serve::parse(&s)?, 201 db_cfg: DbCfg::parse(cfg.section("libeufin-bankdb-postgres"))?, 202 cfg: cfg.clone(), 203 }) 204 } 205 206 pub fn check_regio(&self, amount: &Amount) -> ApiResult<()> { 207 if amount.currency != self.regional_currency { 208 Err(failure( 209 ErrorCode::GENERIC_CURRENCY_MISMATCH, 210 format_args!( 211 "Wrong currency: expected regional currency {} got {}", 212 self.regional_currency, amount.currency 213 ), 214 )) 215 } else { 216 Ok(()) 217 } 218 } 219 220 pub fn check_fiat(&self, amount: &Amount) -> ApiResult<()> { 221 if let Some(fiat_currency) = self.fiat_currency() 222 && amount.currency != *fiat_currency 223 { 224 Err(failure( 225 ErrorCode::GENERIC_CURRENCY_MISMATCH, 226 format_args!( 227 "Wrong currency: expected fiat currency {fiat_currency} got {}", 228 amount.currency 229 ), 230 )) 231 } else { 232 Ok(()) 233 } 234 } 235 236 pub fn fiat_currency(&self) -> Option<&Currency> { 237 self.fiat.as_ref().map(|it| &it.0) 238 } 239 240 /// Builds the taler://withdraw-URI. Such URI will serve the requests 241 /// from wallets, when they need to manage the operation. 242 pub fn taler_withdraw_uri(&self, uuid: Uuid) -> Url { 243 let base = &self.base_url; 244 245 // Determine the correct schema/protocol 246 let protocol = if base.scheme() == "http" { 247 "taler+http" 248 } else { 249 "taler" 250 }; 251 252 // Extract host and optional port 253 let host = base.host_str().unwrap_or(""); 254 let port_suffix = base.port().map(|p| format!(":{}", p)).unwrap_or_default(); 255 256 // Get the path and ensure it handles trailing slashes properly 257 let path = base.path(); 258 259 Url::parse(&format!( 260 "{}://withdraw/{}{}{}taler-integration/{}", 261 protocol, host, port_suffix, path, uuid 262 )) 263 .unwrap() 264 } 265 266 /// Builds the confirmation web UI URL. 267 pub fn withdraw_confirm_url(&self, id: Uuid) -> String { 268 format!("{}webui/#/operation/{}", self.base_url, id) 269 } 270 } 271 272 fn deserialize_bcrypt_cost<'de, D>(deserializer: D) -> Result<u32, D::Error> 273 where 274 D: serde::Deserializer<'de>, 275 { 276 let cost = u32::deserialize(deserializer)?; 277 278 if (4..=31).contains(&cost) { 279 Ok(cost) 280 } else { 281 Err(serde::de::Error::custom(format!( 282 "expected bcrypt cost in [4, 31], got {cost}" 283 ))) 284 } 285 } 286 #[derive(serde::Deserialize)] 287 struct BcryptCfg { 288 #[serde(deserialize_with = "deserialize_bcrypt_cost")] 289 cost: u32, 290 } 291 292 impl From<BcryptCfg> for PwCrypto { 293 fn from(val: BcryptCfg) -> Self { 294 PwCrypto::Bcrypt { cost: val.cost } 295 } 296 }