/*
* 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
}
}
}
}
}