/*
* This file is part of LibEuFin.
* Copyright (C) 2023-2024 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.bank
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.convert
import com.github.ajalt.clikt.parameters.arguments.optional
import com.github.ajalt.clikt.parameters.groups.OptionGroup
import com.github.ajalt.clikt.parameters.groups.cooccurring
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.boolean
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.forwardedheaders.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import org.postgresql.util.PSQLState
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import tech.libeufin.bank.db.AccountDAO.*
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
import java.net.InetAddress
import java.sql.SQLException
import java.util.zip.DataFormatException
import java.util.zip.Inflater
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.io.path.readText
import tech.libeufin.bank.api.*
private val logger: Logger = LoggerFactory.getLogger("libeufin-bank")
// Dirty local variable to stop the server in test TODO remove this ugly hack
var engine: ApplicationEngine? = null
/**
* This plugin checks for body length limit and inflates the requests that have "Content-Encoding: deflate"
*/
val bodyPlugin = createApplicationPlugin("BodyLimitAndDecompression") {
onCallReceive { call ->
// TODO check content length as an optimisation
transformBody { body ->
val bytes = ByteArray(MAX_BODY_LENGTH.toInt() + 1)
var read = 0
when (val encoding = call.request.headers[HttpHeaders.ContentEncoding]) {
"deflate" -> {
// Decompress and check decompressed length
val inflater = Inflater()
while (!body.isClosedForRead) {
body.read { buf ->
inflater.setInput(buf)
try {
read += inflater.inflate(bytes, read, bytes.size - read)
} catch (e: DataFormatException) {
logger.error("Deflated request failed to inflate: ${e.message}")
throw badRequest(
"Could not inflate request",
TalerErrorCode.GENERIC_COMPRESSION_INVALID
)
}
}
if (read > MAX_BODY_LENGTH)
throw badRequest("Decompressed body is suspiciously big > $MAX_BODY_LENGTH B")
}
}
null -> {
// Check body length
while (true) {
val new = body.readAvailable(bytes, read, bytes.size - read)
if (new == -1) break // Channel is closed
read += new
if (read > MAX_BODY_LENGTH)
throw badRequest("Body is suspiciously big > $MAX_BODY_LENGTH B")
}
}
else -> throw unsupportedMediaType(
"Content encoding '$encoding' not supported, expected plain or deflate",
TalerErrorCode.GENERIC_COMPRESSION_INVALID
)
}
ByteReadChannel(bytes, 0, read)
}
}
}
/**
* Set up web server handlers for the Taler corebank API.
*/
fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
install(CallLogging) {
this.level = Level.INFO
this.logger = tech.libeufin.bank.logger
this.format { call ->
val status = call.response.status()
val httpMethod = call.request.httpMethod.value
val path = call.request.path()
val msg = call.logMsg()
if (msg != null) {
"$status, $httpMethod $path, $msg"
} else {
"$status, $httpMethod $path"
}
}
}
install(XForwardedHeaders)
install(CORS) {
anyHost()
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Delete)
allowCredentials = true
}
install(bodyPlugin)
install(IgnoreTrailingSlash)
install(ContentNegotiation) {
json(Json {
@OptIn(ExperimentalSerializationApi::class)
explicitNulls = false
encodeDefaults = true
ignoreUnknownKeys = true
})
}
install(StatusPages) {
status(HttpStatusCode.NotFound) { call, status ->
call.err(
status,
"There is no endpoint defined for the URL provided by the client. Check if you used the correct URL and/or file a report with the developers of the client software.",
TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN
)
}
status(HttpStatusCode.MethodNotAllowed) { call, status ->
call.err(
status,
"The HTTP method used is invalid for this endpoint. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.",
TalerErrorCode.GENERIC_METHOD_INVALID
)
}
exception { call, cause ->
logger.debug("request failed", cause)
when (cause) {
is LibeufinException -> call.err(cause)
is SQLException -> {
when (cause.sqlState) {
PSQLState.SERIALIZATION_FAILURE.state -> call.err(
HttpStatusCode.InternalServerError,
"Transaction serialization failure",
TalerErrorCode.BANK_SOFT_EXCEPTION
)
else -> call.err(
HttpStatusCode.InternalServerError,
"Unexpected sql error with state ${cause.sqlState}",
TalerErrorCode.BANK_UNMANAGED_EXCEPTION
)
}
}
is BadRequestException -> {
/**
* NOTE: extracting the root cause helps with JSON error messages,
* because they mention the particular way they are invalid, but OTOH
* it loses (by getting null) other error messages, like for example
* the one from MissingRequestParameterException. Therefore, in order
* to get the most detailed message, we must consider BOTH sides:
* the 'cause' AND its root cause!
*/
var rootCause: Throwable? = cause.cause
while (rootCause?.cause != null)
rootCause = rootCause.cause
// Telling apart invalid JSON vs missing parameter vs invalid parameter.
val talerErrorCode = when {
cause is MissingRequestParameterException ->
TalerErrorCode.GENERIC_PARAMETER_MISSING
cause is ParameterConversionException ->
TalerErrorCode.GENERIC_PARAMETER_MALFORMED
rootCause is CommonError -> when (rootCause) {
is CommonError.AmountFormat -> TalerErrorCode.BANK_BAD_FORMAT_AMOUNT
is CommonError.AmountNumberTooBig -> TalerErrorCode.BANK_NUMBER_TOO_BIG
is CommonError.Payto -> TalerErrorCode.GENERIC_JSON_INVALID
}
else -> TalerErrorCode.GENERIC_JSON_INVALID
}
call.err(
badRequest(
cause.message,
talerErrorCode,
/* Here getting _some_ error message, by giving precedence
* to the root cause, as otherwise JSON details would be lost. */
rootCause?.message
)
)
}
else -> {
call.err(
HttpStatusCode.InternalServerError,
cause.message,
TalerErrorCode.BANK_UNMANAGED_EXCEPTION
)
}
}
}
}
routing {
coreBankApi(db, ctx)
conversionApi(db, ctx)
bankIntegrationApi(db, ctx)
wireGatewayApi(db, ctx)
revenueApi(db, ctx)
ctx.spaPath?.let {
get("/") {
call.respondRedirect("/webui/")
}
staticFiles("/webui/", it.toFile())
}
}
}
class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = "dbinit") {
private val common by CommonOption()
private val requestReset by option(
"--reset", "-r",
help = "Reset database (DANGEROUS: All existing data is lost)"
).flag()
override fun run() = cliCmd(logger, common.log) {
val config = talerConfig(common.config)
val cfg = config.loadDbConfig()
val ctx = config.loadBankConfig()
Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
db.conn { conn ->
if (requestReset) {
resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank")
}
initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank")
}
// Create admin account if missing
val res = createAdminAccount(db, ctx) // logs provided by the helper
when (res) {
AccountCreationResult.BonusBalanceInsufficient -> {}
AccountCreationResult.LoginReuse -> {}
AccountCreationResult.PayToReuse ->
throw Exception("Failed to create admin's account")
is AccountCreationResult.Success ->
logger.info("Admin's account created")
}
}
}
}
class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") {
private val common by CommonOption()
override fun run() = cliCmd(logger, common.log) {
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
val serverCfg = cfg.loadServerConfig()
Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
if (ctx.allowConversion) {
logger.info("Ensure exchange account exists")
val info = db.account.bankInfo("exchange", ctx.payto)
if (info == null) {
throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled")
} else if (!info.isTalerExchange) {
throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled")
}
logger.info("Ensure conversion is enabled")
val sqlProcedures = Path("${dbCfg.sqlDir}/libeufin-conversion-setup.sql")
if (!sqlProcedures.exists()) {
throw Exception("Missing libeufin-conversion-setup.sql file")
}
db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
} else {
logger.info("Ensure conversion is disabled")
val sqlProcedures = Path("${dbCfg.sqlDir}/libeufin-conversion-drop.sql")
if (!sqlProcedures.exists()) {
throw Exception("Missing libeufin-conversion-drop.sql file")
}
db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
// Remove conversion info from the database ?
}
val env = applicationEngineEnvironment {
when (serverCfg) {
is ServerConfig.Tcp -> {
for (addr in InetAddress.getAllByName(serverCfg.addr)) {
connector {
port = serverCfg.port
host = addr.hostAddress
}
}
}
is ServerConfig.Unix ->
throw Exception("Can only serve libeufin-bank via TCP")
}
module { corebankWebApp(db, ctx) }
}
val local = embeddedServer(Netty, env)
engine = local
local.start(wait = true)
}
}
}
class ChangePw : CliktCommand("Change account password", name = "passwd") {
private val common by CommonOption()
private val username by argument("username", help = "Account username")
private val password by argument(
"password",
help = "Account password used for authentication"
)
override fun run() = cliCmd(logger, common.log) {
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
val res = db.account.reconfigPassword(username, password, null, true)
when (res) {
AccountPatchAuthResult.UnknownAccount ->
throw Exception("Password change for '$username' account failed: unknown account")
AccountPatchAuthResult.OldPasswordMismatch,
AccountPatchAuthResult.TanRequired -> { /* Can never happen */ }
AccountPatchAuthResult.Success ->
logger.info("Password change for '$username' account succeeded")
}
}
}
}
class EditAccount : CliktCommand(
"Edit an existing account",
name = "edit-account"
) {
private val common by CommonOption()
private val username: String by argument(
"username",
help = "Account username"
)
private val name: String? by option(
help = "Legal name of the account owner"
)
private val exchange: Boolean? by option(
hidden = true
).boolean()
private val is_public: Boolean? by option(
"--public",
help = "Make this account visible to anyone"
).boolean()
private val email: String? by option(help = "E-Mail address used for TAN transmission")
private val phone: String? by option(help = "Phone number used for TAN transmission")
private val tan_channel: String? by option(help = "which channel TAN challenges should be sent to")
private val cashout_payto_uri: IbanPayto? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { Payto.parse(it).expectIban() }
private val debit_threshold: TalerAmount? by option(help = "Max debit allowed for this account").convert { TalerAmount(it) }
override fun run() = cliCmd(logger, common.log) {
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
val req = AccountReconfiguration(
name = name,
is_taler_exchange = exchange,
is_public = is_public,
contact_data = ChallengeContactData(
// PATCH semantic, if not given do not change, if empty remove
email = if (email == null) Option.None else Option.Some(if (email != "") email else null),
phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null),
),
cashout_payto_uri = Option.Some(cashout_payto_uri),
debit_threshold = debit_threshold
)
when (patchAccount(db, ctx, req, username, true, true)) {
AccountPatchResult.Success ->
logger.info("Account '$username' edited")
AccountPatchResult.UnknownAccount ->
throw Exception("Account '$username' not found")
AccountPatchResult.MissingTanInfo ->
throw Exception("missing info for tan channel ${req.tan_channel.get()}")
AccountPatchResult.NonAdminName,
AccountPatchResult.NonAdminCashout,
AccountPatchResult.NonAdminDebtLimit,
is AccountPatchResult.TanRequired -> {
// Unreachable as we edit account as admin
}
}
}
}
}
class CreateAccountOption: OptionGroup() {
val username: String by option(
"--username", "-u",
help = "Account unique username"
).required()
val password: String by option(
"--password", "-p",
help = "Account password used for authentication"
).prompt(requireConfirmation = true, hideInput = true)
val name: String by option(
help = "Legal name of the account owner"
).required()
val is_public: Boolean by option(
"--public",
help = "Make this account visible to anyone"
).flag()
val exchange: Boolean by option(
help = "Make this account a taler exchange"
).flag()
val email: String? by option(help = "E-Mail address used for TAN transmission")
val phone: String? by option(help = "Phone number used for TAN transmission")
val cashout_payto_uri: IbanPayto? by option(
help = "Payto URI of a fiant account who receive cashout amount"
).convert { Payto.parse(it).expectIban() }
val payto_uri: Payto? by option(
help = "Payto URI of this account"
).convert { Payto.parse(it) }
val debit_threshold: TalerAmount? by option(
help = "Max debit allowed for this account"
).convert { TalerAmount(it) }
}
class CreateAccount : CliktCommand(
"Create an account, returning the payto://-URI associated with it",
name = "create-account"
) {
private val common by CommonOption()
private val json by argument().convert { Json.decodeFromString(it) }.optional()
private val options by CreateAccountOption().cooccurring()
override fun run() = cliCmd(logger, common.log) {
// TODO support setting tan
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
val req = json ?: options?.run {
RegisterAccountRequest(
username = username,
password = password,
name = name,
is_public = is_public,
is_taler_exchange = exchange,
contact_data = ChallengeContactData(
email = Option.Some(email),
phone = Option.Some(phone),
),
cashout_payto_uri = cashout_payto_uri,
payto_uri = payto_uri,
debit_threshold = debit_threshold
)
}
req?.let {
val result = createAccount(db, ctx, req, true)
when (result) {
AccountCreationResult.BonusBalanceInsufficient ->
throw Exception("Insufficient admin funds to grant bonus")
AccountCreationResult.LoginReuse ->
throw Exception("Account username reuse '${req.username}'")
AccountCreationResult.PayToReuse ->
throw Exception("Bank internalPayToUri reuse")
is AccountCreationResult.Success -> {
logger.info("Account '${req.username}' created")
println(result.payto)
}
}
}
}
}
}
class LibeufinBankCommand : CliktCommand() {
init {
versionOption(getVersion())
subcommands(ServeBank(), BankDbInit(), CreateAccount(), EditAccount(), ChangePw(), CliConfigCmd(BANK_CONFIG_SOURCE))
}
override fun run() = Unit
}
fun main(args: Array) {
LibeufinBankCommand().main(args)
}