config.rs (10857B)
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 continue; 102 } 103 // ENABLED has to be checked before anything else in the section as 104 // currencies.conf may carry disabled sections whose CODE 105 // is not a valid currency and parsing those would abort the whole 106 // configuration. 107 if !s.boolean("enabled").require()? { 108 continue; 109 } 110 if s.currency("code").require()? == *currency { 111 return Ok(CurrencySpecification { 112 name: s.cstr("name").require()?, 113 num_fractional_input_digits: s.number("fractional_input_digits").require()?, 114 num_fractional_normal_digits: s.number("fractional_normal_digits").require()?, 115 num_fractional_trailing_zero_digits: s 116 .number("fractional_trailing_zero_digits") 117 .require()?, 118 alt_unit_names: s.json("alt_unit_names").require()?, 119 }); 120 } 121 } 122 warn!(target: "config", "Missing currency specification for {currency}, using sane defaults"); 123 Ok(CurrencySpecification { 124 name: currency.as_ref().into(), 125 num_fractional_input_digits: 2, 126 num_fractional_normal_digits: 2, 127 num_fractional_trailing_zero_digits: 2, 128 alt_unit_names: [("0".into(), currency.as_ref().into())].into(), 129 }) 130 } 131 132 pub fn parse(cfg: &Config) -> Result<Self, CfgErr> { 133 let s = cfg.section("libeufin-bank"); 134 let base_url = s.base_url("base_url").require()?; 135 let hostname = base_url.host_str().unwrap_or_default().into(); 136 137 let wire_method = s 138 .parse("payment target type", "wire_type") 139 .default(WireMethod::iban)?; 140 141 let currency = s.currency("currency").require()?; 142 let allow_conversion = s.boolean("allow_conversion").default(false)?; 143 let fiat = if allow_conversion { 144 let currency = s.currency("fiat_currency").require()?; 145 let spec = Self::currency_specification(cfg, ¤cy)?; 146 Some((currency, spec)) 147 } else { 148 None 149 }; 150 let zero = Amount::zero(¤cy); 151 let max = Amount::max(¤cy); 152 let mut tan_channels = BTreeMap::new(); 153 for channel in TanChannel::entries { 154 if let Some(path) = s.path(&format!("tan_{channel}")).opt()? { 155 tan_channels.insert( 156 *channel, 157 ( 158 path, 159 s.json(&format!("tan_{channel}_env")) 160 .default(BTreeMap::default())?, 161 ), 162 ); 163 } 164 } 165 Ok(BankCfg { 166 name: s 167 .cstr("name") 168 .default(CompactString::const_new("Taler Bank"))?, 169 base_url, 170 regional_currency: currency, 171 regional_currency_spec: Self::currency_specification(cfg, ¤cy)?, 172 wire_transfer_fees: s.amount("wire_transfer_fees", ¤cy).default(zero)?, 173 min_amount: s 174 .amount("min_wire_transfer_amount", ¤cy) 175 .default(zero)?, 176 max_amount: s 177 .amount("max_wire_transfer_amount", ¤cy) 178 .default(max)?, 179 allow_registration: s.boolean("allow_registration").default(false)?, 180 allow_account_deletion: s.boolean("allow_account_deletion").default(false)?, 181 allow_edit_name: s.boolean("allow_edit_name").default(false)?, 182 allow_edit_cashout: s.boolean("allow_edit_cashout_payto_uri").default(false)?, 183 default_debt_limit: s.amount("default_debt_limit", ¤cy).default(zero)?, 184 registration_bonus: s.amount("registration_bonus", ¤cy).default(zero)?, 185 suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?, 186 fiat, 187 spa_path: s.path("spa").opt()?, 188 fallback_spa: PathBuf::from(cfg.section("paths").path("datadir").require()?) 189 .join("spa"), 190 tan_channels, 191 ctx: PaytoCtx { 192 bic: s.parse("bic", "iban_payto_bic").opt()?, 193 hostname, 194 }, 195 wire_method, 196 pw_crypto: map_config!(s, "password hash algorithm", "pwd_hash_algorithm", 197 "bcrypt" => { 198 s.json::<BcryptCfg>("pwd_hash_config").require()?.into() 199 } 200 ) 201 .require()?, 202 gc_abort_after: s.span("gc_abort_after").require()?, 203 gc_clean_after: s.span("gc_clean_after").require()?, 204 gc_delete_after: s.span("gc_delete_after").require()?, 205 pwd_check_quality: s.boolean("pwd_check").require()?, 206 basic_auth_compat: s.boolean("pwd_auth_compat").require()?, 207 serve: Serve::parse(&s)?, 208 db_cfg: DbCfg::parse(cfg.section("libeufin-bankdb-postgres"))?, 209 cfg: cfg.clone(), 210 }) 211 } 212 213 pub fn check_regio(&self, amount: &Amount) -> ApiResult<()> { 214 if amount.currency != self.regional_currency { 215 Err(failure( 216 ErrorCode::GENERIC_CURRENCY_MISMATCH, 217 format_args!( 218 "Wrong currency: expected regional currency {} got {}", 219 self.regional_currency, amount.currency 220 ), 221 )) 222 } else { 223 Ok(()) 224 } 225 } 226 227 pub fn check_fiat(&self, amount: &Amount) -> ApiResult<()> { 228 if let Some(fiat_currency) = self.fiat_currency() 229 && amount.currency != *fiat_currency 230 { 231 Err(failure( 232 ErrorCode::GENERIC_CURRENCY_MISMATCH, 233 format_args!( 234 "Wrong currency: expected fiat currency {fiat_currency} got {}", 235 amount.currency 236 ), 237 )) 238 } else { 239 Ok(()) 240 } 241 } 242 243 pub fn fiat_currency(&self) -> Option<&Currency> { 244 self.fiat.as_ref().map(|it| &it.0) 245 } 246 247 /// Builds the taler://withdraw-URI. Such URI will serve the requests 248 /// from wallets, when they need to manage the operation. 249 pub fn taler_withdraw_uri(&self, uuid: Uuid) -> Url { 250 let base = &self.base_url; 251 252 // Determine the correct schema/protocol 253 let protocol = if base.scheme() == "http" { 254 "taler+http" 255 } else { 256 "taler" 257 }; 258 259 // Extract host and optional port 260 let host = base.host_str().unwrap_or(""); 261 let port_suffix = base.port().map(|p| format!(":{}", p)).unwrap_or_default(); 262 263 // Get the path and ensure it handles trailing slashes properly 264 let path = base.path(); 265 266 Url::parse(&format!( 267 "{}://withdraw/{}{}{}taler-integration/{}", 268 protocol, host, port_suffix, path, uuid 269 )) 270 .unwrap() 271 } 272 273 /// Builds the confirmation web UI URL. 274 pub fn withdraw_confirm_url(&self, id: Uuid) -> String { 275 format!("{}webui/#/operation/{}", self.base_url, id) 276 } 277 } 278 279 fn deserialize_bcrypt_cost<'de, D>(deserializer: D) -> Result<u32, D::Error> 280 where 281 D: serde::Deserializer<'de>, 282 { 283 let cost = u32::deserialize(deserializer)?; 284 285 if (4..=31).contains(&cost) { 286 Ok(cost) 287 } else { 288 Err(serde::de::Error::custom(format!( 289 "expected bcrypt cost in [4, 31], got {cost}" 290 ))) 291 } 292 } 293 #[derive(serde::Deserialize)] 294 struct BcryptCfg { 295 #[serde(deserialize_with = "deserialize_bcrypt_cost")] 296 cost: u32, 297 } 298 299 impl From<BcryptCfg> for PwCrypto { 300 fn from(val: BcryptCfg) -> Self { 301 PwCrypto::Bcrypt { cost: val.cost } 302 } 303 }