/* * This file is part of LibEuFin. * Copyright (C) 2019 Stanisci and Dold. * 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.sandbox import UtilError import com.fasterxml.jackson.core.util.DefaultIndenter import com.fasterxml.jackson.core.util.DefaultPrettyPrinter import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.KotlinFeature import com.fasterxml.jackson.module.kotlin.KotlinModule import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.output.CliktHelpFormatter import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.types.int import execThrowableOrTerminate import io.ktor.server.application.* import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.plugins.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* import io.ktor.server.plugins.callloging.* import io.ktor.server.plugins.cors.routing.* import io.ktor.util.date.* import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.statements.api.ExposedBlob import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level import org.w3c.dom.Document import startServer import tech.libeufin.util.* import java.math.BigDecimal import java.net.URL import java.security.interfaces.RSAPublicKey import javax.xml.bind.JAXBContext import kotlin.system.exitProcess val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox") const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using the same version. const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION" private val adminPassword: String? = System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD") var WITH_AUTH = true // Needed by helpers too, hence not making it private. // Internal error type. data class SandboxError( val statusCode: HttpStatusCode, val reason: String, val errorCode: LibeufinErrorCode? = null ) : Exception(reason) // HTTP response error type. data class SandboxErrorJson(val error: SandboxErrorDetailJson) data class SandboxErrorDetailJson(val type: String, val description: String) class DefaultExchange : CliktCommand("Set default Taler exchange for a demobank.") { init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of the default exchange") private val exchangePayto by argument("EXCHANGE-PAYTO", "default exchange's payto-address") private val demobank by option("--demobank", help = "Which demobank defaults to EXCHANGE").default("default") override fun run() { val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) execThrowableOrTerminate { dbCreateTables(dbConnString) transaction { val maybeDemobank: DemobankConfigEntity? = DemobankConfigEntity.find { DemobankConfigsTable.name eq demobank }.firstOrNull() if (maybeDemobank == null) { System.err.println("Error, demobank $demobank not found.") exitProcess(1) } val config = maybeDemobank.config /** * Iterating over the config object's field that hold the exchange * base URL and Payto. The iteration is only used to retrieve the * correct names of the DB column 'configKey', because this is named * after such fields. */ listOf( Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl), Pair(config::suggestedExchangePayto, exchangePayto) ).forEach { val maybeConfigPair = DemobankConfigPairEntity.find { DemobankConfigPairsTable.demobankName eq demobank and( DemobankConfigPairsTable.configKey eq it.first.name) }.firstOrNull() /** * The DB doesn't contain any column to hold the exchange URL * or Payto, fail. That should never happen, because the DB row * are created _after_ the DemobankConfig object that _does_ contain * such fields. */ if (maybeConfigPair == null) { System.err.println("Config key '${it.first.name}' for demobank '$demobank' not found in DB.") exitProcess(1) } maybeConfigPair.configValue = it.second } } } } } class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into the database.") { init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } private val nameArgument by argument( "NAME", help = "Name of this configuration. Currently, only 'default' is admitted." ) private val showOption by option( "--show", help = "Only show values, other options will be ignored." ).flag("--no-show", default = false) // FIXME: This really should not be a global option! private val captchaUrlOption by option( "--captcha-url", help = "Needed for browser wallets." ).default("https://bank.demo.taler.net/") private val currencyOption by option("--currency").default("EUR") private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) private val usersDebtLimitOption by option("--users-debt-limit").int().default(1000) private val allowRegistrationsOption by option( "--with-registrations", help = "(defaults to allow registrations)" /* mentioning here as help message did not. */ ).flag("--without-registrations", default = true) private val withSignupBonusOption by option( "--with-signup-bonus", help = "Award new customers with 100 units of currency! (defaults to NO bonus)" ).flag("--without-signup-bonus", default = false) override fun run() { val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) if (nameArgument != "default") { System.err.println("This version admits only the 'default' name") exitProcess(1) } execThrowableOrTerminate { dbCreateTables(dbConnString) val maybeDemobank = transaction { getDemobank(nameArgument) } if (showOption) { if (maybeDemobank != null) { printConfig(maybeDemobank) } else { println("Demobank: $nameArgument not found.") System.exit(1) } return@execThrowableOrTerminate } if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) { System.err.println("Debt numbers can't be negative.") exitProcess(1) } /* Warning if the CAPTCHA URL does not include the {wopid} placeholder. Not a reason to fail because the bank may be run WITHOUT providing Taler. */ if (!hasWopidPlaceholder(captchaUrlOption)) logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." + " Taler withdrawals decrease usability") // The user asks to _set_ values, regardless of overriding or creating. val config = DemobankConfig( currency = currencyOption, bankDebtLimit = bankDebtLimitOption, usersDebtLimit = usersDebtLimitOption, allowRegistrations = allowRegistrationsOption, demobankName = nameArgument, withSignupBonus = withSignupBonusOption, captchaUrl = captchaUrlOption ) /** * The demobank didn't exist. Now: * 1, Store the config values in the database. * 2, Store the demobank name in the database. * 3, Create the admin bank account under this demobank. */ if (maybeDemobank == null) { transaction { insertConfigPairs(config) val demoBank = DemobankConfigEntity.new { this.name = nameArgument } BankAccountEntity.new { iban = getIban() label = "admin" owner = "admin" // Not backed by an actual customer object. // For now, the model assumes always one demobank this.demoBank = demoBank } } } // Demobank exists: update its config values in the database. else transaction { insertConfigPairs(config, override = true) } } } } /** * This command generates Camt53 statements - for all the bank accounts - * every time it gets run. The statements are only stored into the database. * The user should then query either via Ebics or via the JSON interface, * in order to retrieve their statements. */ class Camt053Tick : CliktCommand( "Make a new Camt.053 time tick; all the fresh transactions" + " will be inserted in a new Camt.053 report" ) { override fun run() { val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) execThrowableOrTerminate { dbCreateTables(dbConnString) } val newStatements = mutableMapOf>() /** * For each bank account, extract the latest statement and * include all the later transactions in a new statement. * Build empty statement, if the account does not have any * transaction yet. */ transaction { BankAccountEntity.all().forEach { accountIter -> // Give this account a entry in the final output. newStatements.putIfAbsent(accountIter.label, mutableListOf()) val lastStatement = BankAccountStatementEntity.find { BankAccountStatementsTable.bankAccount eq accountIter.id.value }.lastOrNull() val lastStatementTime = lastStatement?.creationTime ?: 0L BankAccountTransactionEntity.find { BankAccountTransactionsTable.date.greater(lastStatementTime) and( BankAccountTransactionsTable.account eq accountIter.id.value ) }.forEach { newStatements[accountIter.label]?.add( getHistoryElementFromTransactionRow(it) ) ?: run { logger.error("Array operation failed while building statements for account: ${accountIter.label}") System.err.println("Fatal array error while building the statement, please report.") exitProcess(1) } } /** * Resorting the closing (CLBD) balance of the last statement; will * become the PRCD balance of the _new_ one. */ val camtData = buildCamtString( 53, accountIter.iban, newStatements[accountIter.label]!!, currency = accountIter.demoBank.config.currency ) BankAccountStatementEntity.new { statementId = camtData.messageId creationTime = getUTCnow().toInstant().epochSecond xmlMessage = camtData.camtMessage bankAccount = accountIter } } BankAccountFreshTransactionsTable.deleteAll() } } } class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank accounts") { init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } private val creditAccount by option(help = "Label of the bank account receiving the payment").required() private val debitAccount by option(help = "Label of the bank account issuing the payment").required() private val demobankArg by option("--demobank", help = "Which Demobank books this transaction").default("default") private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format") private val subjectArg by argument("SUBJECT", "Payment's subject") override fun run() { /** * Merely connecting here (and NOT creating any table) because this * command should only be run after actual bank accounts exist in the * system, meaning therefore that the database got already set up. */ execThrowableOrTerminate { val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) connectWithSchema(getJdbcConnectionFromPg(pgConnString)) } // Refuse to operate without a default demobank. val demobank = getDemobank("default") if (demobank == null) { System.err.println("Sandbox cannot operate without a 'default' demobank.") System.err.println("Please make one with the 'libeufin-sandbox config' command.") exitProcess(1) } try { wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg, amount) } catch (e: SandboxError) { System.err.println(e.message) exitProcess(1) } catch (e: Exception) { System.err.println(e.message) exitProcess(1) } } } class ResetTables : CliktCommand("Drop all the tables from the database") { init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } override fun run() { val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) execThrowableOrTerminate { dbDropTables(dbConnString) dbCreateTables(dbConnString) } } } class Serve : CliktCommand("Run sandbox HTTP server") { init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } private val auth by option( "--auth", help = "Disable authentication." ).flag("--no-auth", default = true) private val localhostOnly by option( "--localhost-only", help = "Bind only to localhost. On all interfaces otherwise" ).flag("--no-localhost-only", default = true) private val ipv4Only by option( "--ipv4-only", help = "Bind only to ipv4" ).flag(default = false) private val logLevel by option( help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" ) private val port by option().int().default(5000) private val withUnixSocket by option( help = "Bind the Sandbox to the Unix domain socket at PATH. Overrides" + " --port, when both are given", metavar = "PATH" ) private val smsTan by option(help = "Command to send the TAN via SMS." + " The command gets the TAN via STDIN and the phone number" + " as its first parameter" ) private val emailTan by option(help = "Command to send the TAN via e-mail." + " The command gets the TAN via STDIN and the e-mail address as its" + " first parameter.") override fun run() { WITH_AUTH = auth setLogLevel(logLevel) if (WITH_AUTH && adminPassword == null) { System.err.println( "Error: auth is enabled, but env " + "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." + " (Option --no-auth exists for tests)" ) exitProcess(1) } execThrowableOrTerminate { dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) } // Refuse to operate without a 'default' demobank. val demobank = getDemobank("default") if (demobank == null) { System.err.println("Sandbox cannot operate without a 'default' demobank.") System.err.println("Please make one with the 'libeufin-sandbox config' command.") exitProcess(1) } if (withUnixSocket != null) { startServer( withUnixSocket!!, app = sandboxApp ) exitProcess(0) } SMS_TAN_CMD = smsTan EMAIL_TAN_CMD = emailTan logger.info("Starting Sandbox on port ${this.port}") startServerWithIPv4Fallback( options = StartServerOptions( ipv4OnlyOpt = this.ipv4Only, localhostOnlyOpt = this.localhostOnly, portOpt = this.port ), app = sandboxApp ) } } private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { return Demobank( currency = fromDb.config.currency, userDebtLimit = fromDb.config.usersDebtLimit, bankDebtLimit = fromDb.config.bankDebtLimit, allowRegistrations = fromDb.config.allowRegistrations, name = fromDb.name ) } fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { return if (systemID == null) { EbicsSubscriberEntity.find { (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) } } else { EbicsSubscriberEntity.find { (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.systemId eq systemID) } }.firstOrNull() } data class SubscriberKeys( val authenticationPublicKey: RSAPublicKey, val encryptionPublicKey: RSAPublicKey, val signaturePublicKey: RSAPublicKey ) data class EbicsHostPublicInfo( val hostID: String, val encryptionPublicKey: RSAPublicKey, val authenticationPublicKey: RSAPublicKey ) data class BankAccountInfo( val label: String, val name: String, val iban: String, val bic: String, ) inline fun Document.toObject(): T { val jc = JAXBContext.newInstance(T::class.java) val m = jc.createUnmarshaller() return m.unmarshal(this, T::class.java).value } fun ensureNonNull(param: String?): String { return param ?: throw SandboxError( HttpStatusCode.BadRequest, "Bad ID given: $param" ) } class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnEmptyArgs = true) { init { versionOption(getVersion()) } override fun run() = Unit } fun main(args: Array) { SandboxCommand().subcommands( Serve(), ResetTables(), Config(), MakeTransaction(), Camt053Tick(), DefaultExchange() ).main(args) } fun setJsonHandler(ctx: ObjectMapper) { ctx.enable(SerializationFeature.INDENT_OUTPUT) ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) indentObjectsWith(DefaultIndenter(" ", "\n")) }) ctx.registerModule( KotlinModule.Builder() .withReflectionCacheSize(512) .configure(KotlinFeature.NullToEmptyCollection, false) .configure(KotlinFeature.NullToEmptyMap, false) .configure(KotlinFeature.NullIsSameAsDefault, enabled = true) .configure(KotlinFeature.SingletonSupport, enabled = false) .configure(KotlinFeature.StrictNullChecks, false) .build() ) } private suspend fun getWithdrawal(call: ApplicationCall) { val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id")) if (!op.selectionDone && op.reservePub != null) throw internalServerError( "Unselected withdrawal has a reserve public key", LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE ) call.respond(object { val amount = op.amount val aborted = op.aborted val confirmation_done = op.confirmationDone val selection_done = op.selectionDone val selected_reserve_pub = op.reservePub val selected_exchange_account = op.selectedExchangePayto }) } private suspend fun confirmWithdrawal(call: ApplicationCall) { val withdrawalId = call.expectUriComponent("withdrawal_id") logger.debug("Maybe confirming withdrawal: $withdrawalId") transaction { val wo = getWithdrawalOperation(withdrawalId) if (wo.aborted) throw SandboxError( HttpStatusCode.Conflict, "Cannot confirm an aborted withdrawal." ) if (!wo.selectionDone) throw SandboxError( HttpStatusCode.UnprocessableEntity, "Cannot confirm a unselected withdrawal: " + "specify exchange and reserve public key via Integration API first." ) /** * The wallet chose not to select any exchange, use the default. */ val demobank = ensureDemobank(call) if (wo.selectedExchangePayto == null) { wo.selectedExchangePayto = demobank.config.suggestedExchangePayto } val exchangeBankAccount = getBankAccountFromPayto( wo.selectedExchangePayto ?: throw internalServerError( "Cannot withdraw without an exchange." ) ) logger.debug("Withdrawal ${wo.wopid} confirmed? ${wo.confirmationDone}") if (!wo.confirmationDone) { wireTransfer( debitAccount = wo.walletBankAccount, creditAccount = exchangeBankAccount, amount = wo.amount, subject = wo.reservePub ?: throw internalServerError( "Cannot transfer funds without reserve public key." ), // provide the currency. demobank = ensureDemobank(call) ) wo.confirmationDone = true } wo.confirmationDone } call.respond(object {}) } private suspend fun abortWithdrawal(call: ApplicationCall) { val withdrawalId = call.expectUriComponent("withdrawal_id") val operation = getWithdrawalOperation(withdrawalId) if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.") transaction { operation.aborted = true } call.respond(object {}) } val sandboxApp: Application.() -> Unit = { install(CallLogging) { this.level = Level.DEBUG this.logger = tech.libeufin.sandbox.logger this.format { call -> "${call.response.status()}, ${call.request.httpMethod.value} ${call.request.path()}" } } install(CORS) { anyHost() allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.ContentType) allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Delete) allowCredentials = true } install(IgnoreTrailingSlash) install(ContentNegotiation) { register(ContentType.Text.Xml, XMLEbicsConverter()) /** * Content type "text" must go to the XML parser * because Nexus can't set explicitly the Content-Type * (see https://github.com/ktorio/ktor/issues/1127) to * "xml" and the request made gets somehow assigned the * "text/plain" type: */ register(ContentType.Text.Plain, XMLEbicsConverter()) jackson(contentType = ContentType.Application.Json) { setJsonHandler(this) } /** * Make jackson the default parser. It runs also when * the Content-Type request header is missing. */ jackson(contentType = ContentType.Any) { setJsonHandler(this) } } install(StatusPages) { // Bank's fault: it should check the operands. Respond 500 exception { call, cause -> logger.error("Exception while handling '${call.request.uri}', ${cause.stackTraceToString()}") call.respond( HttpStatusCode.InternalServerError, SandboxErrorJson( error = SandboxErrorDetailJson( type = "sandbox-error", description = cause.message ?: "Bank's error: arithmetic exception." ) ) ) } // Not necessarily the bank's fault. exception { call, cause -> logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") call.respond( cause.statusCode, SandboxErrorJson( error = SandboxErrorDetailJson( type = "sandbox-error", description = cause.reason ) ) ) } // Not necessarily the bank's fault. exception { call, cause -> logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") call.respond( cause.statusCode, SandboxErrorJson( error = SandboxErrorDetailJson( type = "util-error", description = cause.reason ) ) ) } /** * Happens when a request fails to parse. This branch triggers * only when a JSON request fails. XML problems are caught within * the /ebicsweb handler and always ultimately rethrown as "EbicsRequestError", * hence they do not reach this branch. */ exception { call, wrapper -> var rootCause = wrapper.cause while (rootCause?.cause != null) rootCause = rootCause.cause val errorMessage: String? = rootCause?.message ?: wrapper.message if (errorMessage == null) { logger.error("The bank didn't detect the cause of a bad request, fail.") logger.error(wrapper.stackTraceToString()) throw SandboxError( HttpStatusCode.InternalServerError, "Did not find bad request details." ) } logger.error(errorMessage) call.respond( HttpStatusCode.BadRequest, SandboxErrorJson( error = SandboxErrorDetailJson( type = "sandbox-error", description = errorMessage ) ) ) } // Catch-all error, respond 500 because the bank didn't handle it. exception { call, cause -> logger.error("Unhandled exception while handling '${call.request.uri}'\n${cause.stackTraceToString()}") call.respond( HttpStatusCode.InternalServerError, SandboxErrorJson( error = SandboxErrorDetailJson( type = "sandbox-error", description = cause.message ?: "Bank's error: unhandled exception." ) ) ) } exception { call, cause -> logger.error("Handling EbicsRequestError: ${cause.message}") respondEbicsTransfer(call, cause.errorText, cause.errorCode) } } intercept(ApplicationCallPipeline.Setup) { val ac: ApplicationCall = call ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH) if (WITH_AUTH) { if(adminPassword == null) { throw internalServerError( "Sandbox has no admin password defined." + " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + "or launch with --no-auth." ) } ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword) } return@intercept } intercept(ApplicationCallPipeline.Fallback) { if (this.call.response.status() == null) { call.respondText( "Not found (no route matched).\n", io.ktor.http.ContentType.Text.Plain, io.ktor.http.HttpStatusCode.NotFound ) return@intercept finish() } } routing { get("/") { call.respondText( "Hello, this is the Sandbox\n", ContentType.Text.Plain ) } // Respond with the last statement of the requesting account. // Query details in the body. post("/admin/payments/camt") { val username = call.request.basicAuth() val body = call.receive() if (body.type != 53) throw SandboxError( HttpStatusCode.NotFound, "Only Camt.053 documents can be generated." ) if (!allowOwnerOrAdmin(username, body.bankaccount)) throw unauthorized("User '${username}' has no rights over" + " bank account '${body.bankaccount}'") val camtMessage = transaction { val bankaccount = getBankAccountFromLabel( body.bankaccount, getDefaultDemobank() ) BankAccountStatementEntity.find { BankAccountStatementsTable.bankAccount eq bankaccount.id }.lastOrNull()?.xmlMessage ?: throw SandboxError( HttpStatusCode.NotFound, "Could not find any statements; please wait next tick" ) } call.respondText( camtMessage, ContentType.Text.Xml, HttpStatusCode.OK ) return@post } /** * Create a new bank account, no EBICS relation. Okay * to let a user, since having a particular username allocates * already a bank account with such label. */ post("/admin/bank-accounts/{label}") { val username = call.request.basicAuth() val body = call.receive() if (!allowOwnerOrAdmin(username, body.label)) throw unauthorized("User '$username' has no rights over" + " bank account '${body.label}'" ) if (body.label == "admin" || body.label == "bank") throw forbidden( "Requested bank account label '${body.label}' not allowed." ) transaction { val maybeBankAccount = BankAccountEntity.find { BankAccountsTable.label eq body.label }.firstOrNull() if (maybeBankAccount != null) throw conflict("Bank account '${body.label}' exist already") // owner username == bank account label val maybeCustomer = DemobankCustomerEntity.find { DemobankCustomersTable.username eq body.label }.firstOrNull() if (maybeCustomer == null) throw notFound("Customer '${body.label}' not found," + " cannot own any bank account.") BankAccountEntity.new { iban = body.iban bic = body.bic label = body.label owner = body.label demoBank = getDefaultDemobank() } } call.respond(object {}) return@post } // Information about one bank account. get("/admin/bank-accounts/{label}") { val username = call.request.basicAuth() val label = call.expectUriComponent("label") val ret = transaction { val demobank = getDefaultDemobank() val bankAccount = getBankAccountFromLabel(label, demobank) if (!allowOwnerOrAdmin(username, label)) throw unauthorized("'${username}' has no rights over '$label'") val balance = getBalance(bankAccount) object { val balance = "${bankAccount.demoBank.config.currency}:${balance}" val iban = bankAccount.iban val bic = bankAccount.bic val label = bankAccount.label } } call.respond(ret) return@get } // Book one incoming payment for the requesting account. // The debtor is not required to have a customer account at this Sandbox. post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { call.request.basicAuth(onlyAdmin = true) val body = call.receive() val accountLabel = ensureNonNull(call.parameters["label"]) val reqDebtorBic = body.debtorBic if (reqDebtorBic != null && !validateBic(reqDebtorBic)) { throw SandboxError( HttpStatusCode.BadRequest, "invalid BIC" ) } val amount = parseAmount(body.amount) transaction { val demobank = getDefaultDemobank() val account = getBankAccountFromLabel( accountLabel, demobank ) val randId = getRandomString(16) val customer = getCustomer(accountLabel) BankAccountTransactionEntity.new { creditorIban = account.iban creditorBic = account.bic creditorName = customer.name ?: "Name not given." debtorIban = body.debtorIban debtorBic = reqDebtorBic debtorName = body.debtorName subject = body.subject this.amount = amount.amount date = getUTCnow().toInstant().toEpochMilli() accountServicerReference = "sandbox-$randId" this.account = account direction = "CRDT" this.demobank = demobank this.currency = demobank.config.currency } } call.respond(object {}) } // Associates a new bank account with an existing Ebics subscriber. post("/admin/ebics/bank-accounts") { call.request.basicAuth(onlyAdmin = true) val body = call.receive() val subscriber = getEbicsSubscriberFromDetails( body.subscriber.userID, body.subscriber.partnerID, body.subscriber.hostID ) val res = insertNewAccount( username = body.label, /** * This value makes only happy the account creator helper. * Logic using this OBSOLETE HTTP handler would NOT expect * to use this password anyway. The reason is that such obsolete * tests access their banking data always through the EBICS * subscriber, needing therefore no HTTP basic password to operate. */ password = "not-used", iban = body.iban ) transaction { subscriber.bankAccount = res.bankAccount } call.respond({}) return@post } // Information about all the default demobank's bank accounts get("/admin/bank-accounts") { call.request.basicAuth(onlyAdmin = true) val accounts = mutableListOf() transaction { val demobank = getDefaultDemobank() // Finds all the accounts of this demobank. BankAccountEntity.find { BankAccountsTable.demoBank eq demobank.id }.forEach { accounts.add( BankAccountInfo( label = it.label, bic = it.bic, iban = it.iban, name = "Bank account owner's name" ) ) } } call.respond(accounts) } // Details of all the transactions of one bank account. get("/admin/bank-accounts/{label}/transactions") { val username = call.request.basicAuth() val ret = AccountTransactions() val accountLabel = ensureNonNull(call.parameters["label"]) if (!allowOwnerOrAdmin(username, accountLabel)) throw unauthorized("Requesting user '${username}'" + " has no rights over bank account '${accountLabel}'" ) transaction { val demobank = getDefaultDemobank() val account = getBankAccountFromLabel(accountLabel, demobank) BankAccountTransactionEntity.find { BankAccountTransactionsTable.account eq account.id }.forEach { ret.payments.add( PaymentInfo( accountLabel = account.label, creditorIban = it.creditorIban, accountServicerReference = it.accountServicerReference, paymentInformationId = it.pmtInfId, debtorIban = it.debtorIban, subject = it.subject, date = GMTDate(it.date).toHttpDate(), amount = it.amount, creditorBic = it.creditorBic, creditorName = it.creditorName, debtorBic = it.debtorBic, debtorName = it.debtorName, currency = it.currency, creditDebitIndicator = when (it.direction) { "CRDT" -> "credit" "DBIT" -> "debit" else -> throw Error("invalid direction") } ) ) } } call.respond(ret) } /** * Generate one incoming and one outgoing transactions for * one bank account. Counterparts do not need to have an account * at this Sandbox. */ post("/admin/bank-accounts/{label}/generate-transactions") { call.request.basicAuth(onlyAdmin = true) transaction { val accountLabel = ensureNonNull(call.parameters["label"]) val demobank = getDefaultDemobank() val account = getBankAccountFromLabel(accountLabel, demobank) val transactionReferenceCrdt = getRandomString(8) val transactionReferenceDbit = getRandomString(8) run { val amount = kotlin.random.Random.nextLong(5, 25) BankAccountTransactionEntity.new { creditorIban = account.iban creditorBic = account.bic creditorName = "Creditor Name" debtorIban = "DE64500105178797276788" debtorBic = "DEUTDEBB101" debtorName = "Max Mustermann" subject = "sample transaction $transactionReferenceCrdt" this.amount = amount.toString() date = getUTCnow().toInstant().toEpochMilli() accountServicerReference = transactionReferenceCrdt this.account = account direction = "CRDT" this.demobank = demobank currency = demobank.config.currency } } run { val amount = kotlin.random.Random.nextLong(5, 25) BankAccountTransactionEntity.new { debtorIban = account.iban debtorBic = account.bic debtorName = "Debitor Name" creditorIban = "DE64500105178797276788" creditorBic = "DEUTDEBB101" creditorName = "Max Mustermann" subject = "sample transaction $transactionReferenceDbit" this.amount = amount.toString() date = getUTCnow().toInstant().toEpochMilli() accountServicerReference = transactionReferenceDbit this.account = account direction = "DBIT" this.demobank = demobank currency = demobank.config.currency } } } call.respond(object {}) } /** * Create a new EBICS subscriber without associating * a bank account to it. Currently every registered * user is allowed to call this. */ post("/admin/ebics/subscribers") { call.request.basicAuth(onlyAdmin = true) val body = call.receive() transaction { // Check the host ID exists. EbicsHostEntity.find { EbicsHostsTable.hostID eq body.hostID }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") // Check it exists first. val maybeSubscriber = EbicsSubscriberEntity.find { EbicsSubscribersTable.userId eq body.userID and ( EbicsSubscribersTable.partnerId eq body.partnerID ) and (EbicsSubscribersTable.systemId eq body.systemID) and (EbicsSubscribersTable.hostId eq body.hostID) }.firstOrNull() if (maybeSubscriber != null) throw conflict("EBICS subscriber exists already") EbicsSubscriberEntity.new { partnerId = body.partnerID userId = body.userID systemId = null hostId = body.hostID state = SubscriberState.NEW nextOrderID = 1 } } call.respondText( "Subscriber created.", ContentType.Text.Plain, HttpStatusCode.OK ) return@post } // Shows details of all the EBICS subscribers of this Sandbox. get("/admin/ebics/subscribers") { call.request.basicAuth(onlyAdmin = true) val ret = AdminGetSubscribers() transaction { EbicsSubscriberEntity.all().forEach { ret.subscribers.add( EbicsSubscriberInfo( userID = it.userId, partnerID = it.partnerId, hostID = it.hostId, demobankAccountLabel = it.bankAccount?.label ?: "not associated yet" ) ) } } call.respond(ret) return@get } // Change keys used in the EBICS communications. post("/admin/ebics/hosts/{hostID}/rotate-keys") { call.request.basicAuth(onlyAdmin = true) val hostID: String = call.parameters["hostID"] ?: throw SandboxError( io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" ) transaction { val host = EbicsHostEntity.find { EbicsHostsTable.hostID eq hostID }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Host $hostID not found" ) val pairA = CryptoUtil.generateRsaKeyPair(2048) val pairB = CryptoUtil.generateRsaKeyPair(2048) val pairC = CryptoUtil.generateRsaKeyPair(2048) host.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) host.signaturePrivateKey = ExposedBlob(pairC.private.encoded) } call.respondText( "Keys of '${hostID}' rotated.", ContentType.Text.Plain, HttpStatusCode.OK ) return@post } // Create a new EBICS host post("/admin/ebics/hosts") { call.request.basicAuth(onlyAdmin = true) val req = call.receive() val pairA = CryptoUtil.generateRsaKeyPair(2048) val pairB = CryptoUtil.generateRsaKeyPair(2048) val pairC = CryptoUtil.generateRsaKeyPair(2048) transaction { val maybeHost = EbicsHostEntity.find { EbicsHostsTable.hostID eq req.hostID }.firstOrNull() if (maybeHost != null) { logger.info("EBICS host '${req.hostID}' exists already, this request conflicts.") throw conflict("EBICS host '${req.hostID}' exists already") } EbicsHostEntity.new { this.ebicsVersion = req.ebicsVersion this.hostId = req.hostID this.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) this.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) this.signaturePrivateKey = ExposedBlob(pairC.private.encoded) } } call.respondText( "Host '${req.hostID}' created.", ContentType.Text.Plain, HttpStatusCode.OK ) return@post } // Show the names of all the Ebics hosts get("/admin/ebics/hosts") { call.request.basicAuth(onlyAdmin = true) val ebicsHosts = transaction { EbicsHostEntity.all().map { it.hostId } } call.respond(EbicsHostsResponse(ebicsHosts)) } // Process one EBICS request post("/ebicsweb") { try { call.ebicsweb() } /** * The catch blocks try to extract a EBICS error message from the * exception type being handled. NOT logging under each catch block * as ultimately the registered exception handler is expected to log. */ catch (e: UtilError) { throw EbicsProcessingError("Serving EBICS threw unmanaged UtilError: ${e.reason}") } catch (e: SandboxError) { val errorInfo: String = e.message ?: e.stackTraceToString() logger.info(errorInfo) // Should translate to EBICS error code. when (e.errorCode) { LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw EbicsProcessingError("Invalid bank state.") LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw EbicsProcessingError("Inconsistent bank state.") else -> throw EbicsProcessingError("Unknown Libeufin error code: ${e.errorCode}.") } } catch (e: EbicsNoDownloadDataAvailable) { respondEbicsTransfer(call, e.errorText, e.errorCode) } catch (e: EbicsRequestError) { /** * Preventing the last catch-all block from handling * a known error type. Rethrowing here to let the top-level * handler take action. */ throw e } catch (e: Exception) { logger.error(e.stackTraceToString()) throw EbicsProcessingError(e.message) } return@post } /** * Create a new demobank instance with a particular currency, * debt limit and possibly other configuration * (could also be a CLI command for now) */ post("/demobanks") { throw NotImplementedError("Feature only available at the libeufin-sandbox CLI") } get("/demobanks") { expectAdmin(call.request.basicAuth()) val ret = object { val demoBanks = mutableListOf() } transaction { DemobankConfigEntity.all().forEach { ret.demoBanks.add(getJsonFromDemobankConfig(it)) } } call.respond(ret) return@get } get("/demobanks/{demobankid}") { val demobank = ensureDemobank(call) expectAdmin(call.request.basicAuth()) call.respond(getJsonFromDemobankConfig(demobank)) return@get } route("/demobanks/{demobankid}") { // NOTE: TWG assumes that username == bank account label. route("/taler-wire-gateway") { post("/{exchangeUsername}/admin/add-incoming") { val username = call.expectUriComponent("exchangeUsername") val usernameAuth = call.request.basicAuth() if (username != usernameAuth) throw forbidden("Bank account name and username differ: $username vs $usernameAuth") logger.debug("TWG add-incoming passed authentication") val body = try { call.receive() } catch (e: Exception) { logger.error("/admin/add-incoming failed at parsing the request body") throw SandboxError( HttpStatusCode.BadRequest, "Invalid request" ) } val singletonTx = transaction { val demobank = ensureDemobank(call) val bankAccountCredit = getBankAccountFromLabel(username, demobank) if (bankAccountCredit.owner != username) throw forbidden( "User '$username' cannot access bank account with label: $username." ) val bankAccountDebit = getBankAccountFromPayto(body.debit_account) logger.debug("TWG add-incoming about to wire transfer") val ref = wireTransfer( bankAccountDebit.label, bankAccountCredit.label, demobank.name, body.reserve_pub, body.amount ) /** * The remaining part aims at returning an x-libeufin-bank-formatted * message to Nexus, to let it ingest the (incoming side of the) payment * information. The format choice makes it more practical for Nexus, * because it handles this format already for the x-libeufin-bank connection * type. */ val incomingTx = BankAccountTransactionEntity.find { BankAccountTransactionsTable.accountServicerReference eq ref and ( BankAccountTransactionsTable.direction eq "CRDT" ) // closes the 'and'. }.firstOrNull() if (incomingTx == null) throw internalServerError("Just created transaction not found in DB. AcctSvcrRef: $ref") val incomingHistoryElement = getHistoryElementFromTransactionRow(incomingTx) logger.debug("TWG add-incoming has wire transferred, AcctSvcrRef: $ref") incomingHistoryElement } val resp = object { val transactions = listOf(singletonTx) } call.respond(resp) return@post } } // Talk to wallets. route("/integration-api") { get("/config") { val demobank = ensureDemobank(call) call.respond(SandboxConfig( name = "taler-bank-integration", version = PROTOCOL_VERSION_UNIFIED, currency = demobank.config.currency )) return@get } post("/withdrawal-operation/{wopid}") { val arg = ensureNonNull(call.parameters["wopid"]) val withdrawalUuid = parseUuid(arg) val body = call.receive() val transferDone = transaction { val wo = TalerWithdrawalEntity.find { TalerWithdrawalsTable.wopid eq withdrawalUuid }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Withdrawal operation $withdrawalUuid not found." ) if (wo.confirmationDone) { return@transaction true } if (wo.selectionDone) { if (body.reserve_pub != wo.reservePub) throw SandboxError( HttpStatusCode.Conflict, "Selecting a different reserve from the one already selected" ) if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( HttpStatusCode.Conflict, "Selecting a different exchange from the one already selected" ) return@transaction false } // Flow here means never selected, hence must as well never be paid. if (wo.confirmationDone) throw internalServerError( "Withdrawal ${wo.wopid} knew NO exchange and reserve pub, " + "but is marked as paid!" ) wo.reservePub = body.reserve_pub wo.selectedExchangePayto = body.selected_exchange wo.selectionDone = true false } call.respond(object { val transfer_done: Boolean = transferDone }) return@post } get("/withdrawal-operation/{wopid}") { val arg = ensureNonNull(call.parameters["wopid"]) val maybeWithdrawalUuid = parseUuid(arg) val maybeWithdrawalOp = transaction { TalerWithdrawalEntity.find { TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Withdrawal operation: $arg not found" ) } val demobank = ensureDemobank(call) val captchaPage: String? = demobank.config.captchaUrl?.replace("{wopid}",arg) if (captchaPage == null) throw internalServerError("demobank ${demobank.name} lacks the CAPTCHA URL from the configuration.") val ret = TalerWithdrawalStatus( selection_done = maybeWithdrawalOp.selectionDone, transfer_done = maybeWithdrawalOp.confirmationDone, amount = maybeWithdrawalOp.amount, suggested_exchange = demobank.config.suggestedExchangeBaseUrl, aborted = maybeWithdrawalOp.aborted, confirm_transfer_url = captchaPage ) call.respond(ret) return@get } } route("/circuit-api") { circuitApi(this) } // Talk to Web UI. route("/access-api") { post("/accounts/{account_name}/transactions") { val username = call.request.basicAuth() val demobank = ensureDemobank(call) val bankAccount = getBankAccountFromLabel( call.expectUriComponent("account_name"), demobank ) // note: admin has no rights to create transactions on non-admin accounts. val authGranted: Boolean = !WITH_AUTH if (!authGranted && username != bankAccount.label) throw unauthorized("Username '$username' has no rights over bank account ${bankAccount.label}") val req = call.receive() val payto = parsePayto(req.paytoUri) val amount: String? = payto.amount ?: req.amount if (amount == null) throw badRequest("Amount is missing") /** * The transaction block below lets the 'demoBank' field * of 'bankAccount' be correctly accessed. */ transaction { wireTransfer( debitAccount = bankAccount, creditAccount = getBankAccountFromIban(payto.iban), demobank = bankAccount.demoBank, subject = payto.message ?: throw badRequest( "'message' query parameter missing in Payto address" ), amount = amount, pmtInfId = req.pmtInfId ) } call.respond(object {}) return@post } // Information about one withdrawal. get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { getWithdrawal(call) return@get } // account-less style: get("/withdrawals/{withdrawal_id}") { getWithdrawal(call) return@get } // Create a new withdrawal operation. post("/accounts/{account_name}/withdrawals") { var username = call.request.basicAuth() val demobank = ensureDemobank(call) /** * Check here if the user has the right over the claimed bank account. After * this check, the withdrawal operation will be allowed only by providing its * UID. */ val maybeOwnedAccount = getBankAccountFromLabel( call.expectUriComponent("account_name"), demobank ) val authGranted = !WITH_AUTH // note: admin not allowed on non-admin accounts if (!authGranted && maybeOwnedAccount.owner != username) throw unauthorized("Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'") val req = call.receive() // Check for currency consistency val amount = parseAmount(req.amount) if (amount.currency != demobank.config.currency) throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.config.currency}") // Check funds are sufficient. if ( maybeDebit( maybeOwnedAccount.label, BigDecimal(amount.amount), transaction { maybeOwnedAccount.demoBank.name } )) { logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing") throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") } val wo: TalerWithdrawalEntity = transaction { TalerWithdrawalEntity.new { this.amount = req.amount walletBankAccount = maybeOwnedAccount } } val baseUrl = URL(call.request.getBaseUrl()) val withdrawUri = url { protocol = URLProtocol( name = "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), defaultPort = -1 ) host = "withdraw" val pathSegments = mutableListOf( /** * encodes the hostname(+port) of the actual * bank that will serve the withdrawal request. */ baseUrl.host.plus( if (baseUrl.port != -1) ":${baseUrl.port}" else "" ) ) /** * Slashes can only be intermediate and single, * any other combination results in badly formed URIs. * The following loop ensure this for the current URI path. * This might even come from X-Forwarded-Prefix. */ baseUrl.path.split("/").forEach { if (it.isNotEmpty()) pathSegments.add(it) } pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}") this.appendPathSegments(pathSegments) } call.respond(object { val withdrawal_id = wo.wopid.toString() val taler_withdraw_uri = withdrawUri }) return@post } // Confirm a withdrawal: no basic auth, because the ID should be unguessable. post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { confirmWithdrawal(call) return@post } // account-less style: post("/withdrawals/{withdrawal_id}/confirm") { confirmWithdrawal(call) return@post } // Aborting withdrawals: post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") { abortWithdrawal(call) return@post } // account-less style: post("/withdrawals/{withdrawal_id}/abort") { abortWithdrawal(call) return@post } // Bank account basic information. get("/accounts/{account_name}") { val username = call.request.basicAuth() val accountAccessed = call.expectUriComponent("account_name") val demobank = ensureDemobank(call) val bankAccount = getBankAccountFromLabel(accountAccessed, demobank) val authGranted = !WITH_AUTH || bankAccount.isPublic || username == "admin" if (!authGranted && bankAccount.owner != username) throw forbidden("Customer '$username' cannot access bank account '$accountAccessed'") val balance = getBalance(bankAccount) logger.debug("Balance of '$username': ${balance.toPlainString()}") call.respond(object { val balance = object { val amount = "${demobank.config.currency}:${balance.abs().toPlainString()}" val credit_debit_indicator = if (balance < BigDecimal.ZERO) "debit" else "credit" } val paytoUri = buildIbanPaytoUri( iban = bankAccount.iban, bic = bankAccount.bic, // username 'null' should only happen when auth is disabled. receiverName = getPersonNameFromCustomer(bankAccount.owner) ) val iban = bankAccount.iban // The Elvis operator helps the --no-auth case, // where username would be empty val debitThreshold = getMaxDebitForUser( username = username ?: "admin", demobankName = demobank.name ).toString() }) return@get } get("/accounts/{account_name}/transactions/{tId}") { val username = call.request.basicAuth() val demobank = ensureDemobank(call) val bankAccount = getBankAccountFromLabel( call.expectUriComponent("account_name"), demobank ) val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" if (!authGranted && username != bankAccount.owner) throw forbidden("Cannot access bank account ${bankAccount.label}") val tId = call.parameters["tId"] ?: throw badRequest("URI didn't contain the transaction ID") val tx: BankAccountTransactionEntity? = transaction { BankAccountTransactionEntity.find { BankAccountTransactionsTable.accountServicerReference eq tId }.firstOrNull() } if (tx == null) throw notFound("Transaction $tId wasn't found") call.respond(getHistoryElementFromTransactionRow(tx)) return@get } get("/accounts/{account_name}/transactions") { val username = call.request.basicAuth() val demobank = ensureDemobank(call) val bankAccount = getBankAccountFromLabel( call.expectUriComponent("account_name"), demobank ) val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" if (!authGranted && bankAccount.owner != username) throw forbidden("Cannot access bank account ${bankAccount.label}") // Paging values. val page: Int = expectInt(call.request.queryParameters["page"] ?: "1") if (page < 1) throw badRequest("'page' param is less than 1") val size: Int = expectInt(call.request.queryParameters["size"] ?: "5") if (size < 1) throw badRequest("'size' param is less than 1") // Time range filter values val fromMs = expectLong(call.request.queryParameters["from_ms"] ?: "0") if (fromMs < 0) throw badRequest("'from_ms' param is less than 0") val untilMs = expectLong(call.request.queryParameters["until_ms"] ?: Long.MAX_VALUE.toString()) if (untilMs < 0) throw badRequest("'until_ms' param is less than 0") val longPollMs: Long? = call.maybeLong("long_poll_ms") // LISTEN, if Postgres. val listenHandle = if (isPostgres() && longPollMs != null) { val channelName = buildChannelName( NotificationsChannelDomains.LIBEUFIN_REGIO_TX, call.expectUriComponent("account_name") ) val listenHandle = PostgresListenHandle(channelName) // Can't LISTEN on the same DB TX that checks for data, as Exposed // closes that connection and the notification getter would fail. // Can't invoke the notification getter in the same DB TX either, // as it would block the DB. listenHandle.postgresListen() listenHandle } else null val historyParams = HistoryParams( pageNumber = page, pageSize = size, bankAccount = bankAccount, fromMs = fromMs, untilMs = untilMs ) var ret: List = transaction { extractTxHistory(historyParams) } logger.debug("Is payment data empty? ${ret.isEmpty()}") // Data was found already, UNLISTEN and respond. if (listenHandle != null && ret.isNotEmpty()) { logger.debug("No need to wait DB events, payment data found.") listenHandle.postgresUnlisten() call.respond(object {val transactions = ret}) return@get } // No data was found, sleep until the timeout or getting woken up. // Third condition only silences the compiler. if (listenHandle != null && longPollMs != null) { logger.debug("Waiting DB event for new payment data.") val notificationArrived = listenHandle.waitOnIODispatchers(longPollMs) // Only if the awaited event fired, query again the DB. if (notificationArrived) { ret = transaction { // Refreshing to update the index to the very last transaction. historyParams.bankAccount.refresh() extractTxHistory(historyParams) } } } call.respond(object {val transactions = ret}) return@get } get("/public-accounts") { val demobank = ensureDemobank(call) val ret = object { val publicAccounts = mutableListOf() } transaction { BankAccountEntity.find { BankAccountsTable.isPublic eq true and( BankAccountsTable.demoBank eq demobank.id ) }.forEach { val balanceIter = getBalance(it) ret.publicAccounts.add( PublicAccountInfo( balance = "${demobank.config.currency}:$balanceIter", iban = it.iban, accountLabel = it.label ) ) } } call.respond(ret) return@get } delete("accounts/{account_name}") { val username = call.request.basicAuth() val demobank = ensureDemobank(call) val authGranted = !WITH_AUTH || username == "admin" val bankAccountLabel = call.expectUriComponent("account_name") /** * This helper fails if the demobank that is mentioned in the URI * is not hosting the account to be deleted. */ val bankAccount = getBankAccountFromLabel( bankAccountLabel, demobank ) if (!authGranted && username != bankAccount.owner) throw unauthorized("User '$username' has no rights to delete bank account '$bankAccountLabel'") transaction { val customerAccount = getCustomer(bankAccount.owner) bankAccount.delete() customerAccount.delete() } call.respond(object {}) return@delete } // Keeping the prefix "testing" not to break tests. post("/testing/register") { // Check demobank was created. val demobank = ensureDemobank(call) if (!demobank.config.allowRegistrations) { throw SandboxError( HttpStatusCode.UnprocessableEntity, "The bank doesn't allow new registrations at the moment." ) } val req = call.receive() val newAccount = insertNewAccount( req.username, req.password, name = req.name, iban = req.iban, demobank = demobank.name, isPublic = req.isPublic ) val balance = getBalance(newAccount.bankAccount) call.respond(object { val balance = getBalanceForJson(balance, demobank.config.currency) val paytoUri = buildIbanPaytoUri( iban = newAccount.bankAccount.iban, bic = newAccount.bankAccount.bic, receiverName = getPersonNameFromCustomer(req.username) ) val iban = newAccount.bankAccount.iban val debitThreshold = getMaxDebitForUser( req.username, demobank.name ).toString() }) return@post } } route("/ebics") { /** * Associate an existing bank account to one EBICS subscriber. * If the subscriber is not found, it is created. */ post("/subscribers") { // Only the admin can create Ebics subscribers. val user = call.request.basicAuth() if (WITH_AUTH && (user != "admin")) throw forbidden("Only the Administrator can create Ebics subscribers.") val body = call.receive() // Create or get the Ebics subscriber that is found. transaction { // Check that host ID exists EbicsHostEntity.find { EbicsHostsTable.hostID eq body.hostID }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") val subscriber: EbicsSubscriberEntity = EbicsSubscriberEntity.find { (EbicsSubscribersTable.partnerId eq body.partnerID).and( EbicsSubscribersTable.userId eq body.userID ).and(EbicsSubscribersTable.hostId eq body.hostID) }.firstOrNull() ?: EbicsSubscriberEntity.new { partnerId = body.partnerID userId = body.userID systemId = null hostId = body.hostID state = SubscriberState.NEW nextOrderID = 1 } val bankAccount = getBankAccountFromLabel( body.demobankAccountLabel, ensureDemobank(call) ) subscriber.bankAccount = bankAccount } call.respond(object {}) return@post } } } } }