/*
* 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 com.fasterxml.jackson.core.JsonParseException
import io.ktor.application.ApplicationCallPipeline
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import io.ktor.jackson.jackson
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import org.w3c.dom.Document
import tech.libeufin.util.CryptoUtil
import tech.libeufin.util.RawPayment
import java.lang.ArithmeticException
import java.math.BigDecimal
import java.security.interfaces.RSAPublicKey
import javax.xml.bind.JAXBContext
import com.fasterxml.jackson.core.util.DefaultIndenter
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import org.jetbrains.exposed.sql.statements.api.ExposedBlob
import java.time.Instant
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.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.versionOption
import com.github.ajalt.clikt.parameters.types.int
import execThrowableOrTerminate
import io.ktor.application.ApplicationCall
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.request.*
import io.ktor.util.date.*
import tech.libeufin.sandbox.BankAccountTransactionsTable.accountServicerReference
import tech.libeufin.sandbox.BankAccountTransactionsTable.amount
import tech.libeufin.sandbox.BankAccountTransactionsTable.creditorBic
import tech.libeufin.sandbox.BankAccountTransactionsTable.creditorIban
import tech.libeufin.sandbox.BankAccountTransactionsTable.creditorName
import tech.libeufin.sandbox.BankAccountTransactionsTable.currency
import tech.libeufin.sandbox.BankAccountTransactionsTable.date
import tech.libeufin.sandbox.BankAccountTransactionsTable.debtorBic
import tech.libeufin.sandbox.BankAccountTransactionsTable.debtorIban
import tech.libeufin.sandbox.BankAccountTransactionsTable.debtorName
import tech.libeufin.sandbox.BankAccountTransactionsTable.direction
import tech.libeufin.sandbox.BankAccountTransactionsTable.pmtInfId
import tech.libeufin.util.*
import tech.libeufin.util.ebics_h004.EbicsResponse
import tech.libeufin.util.ebics_h004.EbicsTypes
import java.net.BindException
import java.util.*
import kotlin.random.Random
import kotlin.system.exitProcess
val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION"
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox")
data class SandboxError(val statusCode: HttpStatusCode, val reason: String) : Exception()
data class SandboxErrorJson(val error: SandboxErrorDetailJson)
data class SandboxErrorDetailJson(val type: String, val description: String)
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 logLevel by option()
private val port by option().int().default(5000)
override fun run() {
setLogLevel(logLevel)
serverMain(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME), port)
}
}
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 Subscriber(
val partnerID: String,
val userID: String,
val systemID: String?,
val keys: SubscriberKeys
)
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
)
data class BankAccountsListReponse(
val accounts: List
)
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 BigDecimal.signToString(): String {
return if (this.signum() > 0) "+" else ""
// minus sign is added by default already.
}
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()).main(args)
}
suspend inline fun ApplicationCall.receiveJson(): T {
try {
return this.receive()
} catch (e: MissingKotlinParameterException) {
throw SandboxError(HttpStatusCode.BadRequest, "Missing value for ${e.pathReference}")
} catch (e: MismatchedInputException) {
// Note: POSTing "[]" gets here but e.pathReference is blank.
throw SandboxError(HttpStatusCode.BadRequest, "Invalid value for '${e.pathReference}'")
} catch (e: JsonParseException) {
throw SandboxError(HttpStatusCode.BadRequest, "Invalid JSON")
}
}
fun serverMain(dbName: String, port: Int) {
execThrowableOrTerminate { dbCreateTables(dbName) }
val myLogger = logger
val server = embeddedServer(Netty, port = port) {
install(CallLogging) {
this.level = Level.DEBUG
this.logger = myLogger
}
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
indentObjectsWith(DefaultIndenter(" ", "\n"))
})
registerModule(KotlinModule(nullisSameAsDefault = true))
//registerModule(JavaTimeModule())
}
}
install(StatusPages) {
exception { cause ->
logger.error("Exception while handling '${call.request.uri}'", cause)
call.respondText(
"Invalid arithmetic attempted.",
ContentType.Text.Plain,
// here is always the bank's fault, as it should always check
// the operands.
HttpStatusCode.InternalServerError
)
}
exception { cause ->
val resp = EbicsResponse.createForUploadWithError(
cause.errorText,
cause.errorCode,
// assuming that the phase is always transfer,
// as errors during initialization should have
// already been caught by the chunking logic.
EbicsTypes.TransactionPhaseType.TRANSFER
)
val hostAuthPriv = transaction {
val host = EbicsHostEntity.find {
EbicsHostsTable.hostID.upperCase() eq call.attributes.get(EbicsHostIdAttribute).toUpperCase()
}.firstOrNull() ?: throw SandboxError(
HttpStatusCode.InternalServerError,
"Requested Ebics host ID not found."
)
CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes)
}
call.respondText(
XMLUtil.signEbicsResponse(resp, hostAuthPriv),
ContentType.Application.Xml,
HttpStatusCode.OK
)
}
exception { cause ->
logger.error("Exception while handling '${call.request.uri}'", cause)
call.respond(
cause.statusCode,
SandboxErrorJson(
error = SandboxErrorDetailJson(
type = "sandbox-error",
description = cause.reason
)
)
)
}
exception { cause ->
logger.error("Exception while handling '${call.request.uri}'", cause)
call.respondText("Internal server error.", ContentType.Text.Plain, HttpStatusCode.InternalServerError)
}
}
intercept(ApplicationCallPipeline.Fallback) {
if (this.call.response.status() == null) {
call.respondText("Not found (no route matched).\n", ContentType.Text.Plain, HttpStatusCode.NotFound)
return@intercept finish()
}
}
routing {
static("/static") {
resources("static")
}
get("/") {
call.respondText("Hello, this is Sandbox\n", ContentType.Text.Plain)
}
get("/config") {
call.respond(object {
val name = "libeufin-sandbox"
// FIXME: use actual version here!
val version = "0.0.0-dev.0"
})
}
// only reason for a post is to hide the iban (to some degree.)
post("/admin/payments/camt") {
val body = call.receiveJson()
val history = historyForAccount(body.iban)
SandboxAssert(body.type == 53, "Only Camt.053 is implemented")
val camt53 = buildCamtString(body.type, body.iban, history)
call.respondText(camt53, ContentType.Text.Xml, HttpStatusCode.OK)
return@post
}
/**
* Adds a new payment to the book.
*/
post("/admin/payments") {
val body = call.receiveJson()
val randId = getRandomString(16)
transaction {
val localIban = if (body.direction == "DBIT") body.debitorIban else body.creditorIban
BankAccountTransactionsTable.insert {
it[creditorIban] = body.creditorIban
it[creditorBic] = body.creditorBic
it[creditorName] = body.creditorName
it[debtorIban] = body.debitorIban
it[debtorBic] = body.debitorBic
it[debtorName] = body.debitorName
it[subject] = body.subject
it[amount] = body.amount
it[currency] = body.currency
it[date] = Instant.now().toEpochMilli()
it[accountServicerReference] = "sandbox-$randId"
it[account] = getBankAccountFromIban(localIban).id
it[direction] = body.direction
}
}
call.respondText("Payment created")
return@post
}
post("/admin/bank-accounts/{label}/simulate-incoming-transaction") {
val body = call.receiveJson()
// FIXME: generate nicer UUID!
val accountLabel = ensureNonNull(call.parameters["label"])
transaction {
val account = getBankAccountFromLabel(accountLabel)
val randId = getRandomString(16)
BankAccountTransactionsTable.insert {
it[creditorIban] = account.iban
it[creditorBic] = account.bic
it[creditorName] = account.name
it[debtorIban] = body.debtorIban
it[debtorBic] = body.debtorBic
it[debtorName] = body.debtorName
it[subject] = body.subject
it[amount] = body.amount
it[currency] = account.currency
it[date] = Instant.now().toEpochMilli()
it[accountServicerReference] = "sandbox-$randId"
it[BankAccountTransactionsTable.account] = account.id
it[direction] = "CRDT"
}
}
call.respond(object {})
}
/**
* Associates a new bank account with an existing Ebics subscriber.
*/
post("/admin/ebics/bank-accounts") {
val body = call.receiveJson()
transaction {
val subscriber = getEbicsSubscriberFromDetails(
body.subscriber.userID,
body.subscriber.partnerID,
body.subscriber.hostID
)
BankAccountEntity.new {
this.subscriber = subscriber
iban = body.iban
bic = body.bic
name = body.name
label = body.label
currency = body.currency.toUpperCase(Locale.ROOT)
}
}
call.respondText("Bank account created")
return@post
}
get("/admin/bank-accounts") {
val accounts = mutableListOf()
transaction {
BankAccountEntity.all().forEach {
accounts.add(
BankAccountInfo(
label = it.label,
name = it.name,
bic = it.bic,
iban = it.iban
)
)
}
}
call.respond(accounts)
}
get("/admin/bank-accounts/{label}/transactions") {
val ret = AccountTransactions()
transaction {
val accountLabel = ensureNonNull(call.parameters["label"])
transaction {
val account = getBankAccountFromLabel(accountLabel)
BankAccountTransactionsTable.select { BankAccountTransactionsTable.account eq account.id }
.forEach {
ret.payments.add(
PaymentInfo(
accountLabel = account.label,
creditorIban = it[creditorIban],
// FIXME: We need to modify the transactions table to have an actual
// account servicer reference here.
accountServicerReference = it[accountServicerReference],
paymentInformationId = it[pmtInfId],
debtorIban = it[debtorIban],
subject = it[BankAccountTransactionsTable.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)
}
post("/admin/bank-accounts/{label}/generate-transactions") {
transaction {
val accountLabel = ensureNonNull(call.parameters["label"])
val account = getBankAccountFromLabel(accountLabel)
val transactionReferenceCrdt = getRandomString(8)
val transactionReferenceDbit = getRandomString(8)
run {
val amount = Random.nextLong(5, 25)
BankAccountTransactionsTable.insert {
it[creditorIban] = account.iban
it[creditorBic] = account.bic
it[creditorName] = account.name
it[debtorIban] = "DE64500105178797276788"
it[debtorBic] = "DEUTDEBB101"
it[debtorName] = "Max Mustermann"
it[subject] = "sample transaction $transactionReferenceCrdt"
it[BankAccountTransactionsTable.amount] = amount.toString()
it[currency] = account.currency
it[date] = Instant.now().toEpochMilli()
it[accountServicerReference] = transactionReferenceCrdt
it[BankAccountTransactionsTable.account] = account.id
it[direction] = "CRDT"
}
}
run {
val amount = Random.nextLong(5, 25)
BankAccountTransactionsTable.insert {
it[debtorIban] = account.iban
it[debtorBic] = account.bic
it[debtorName] = account.name
it[creditorIban] = "DE64500105178797276788"
it[creditorBic] = "DEUTDEBB101"
it[creditorName] = "Max Mustermann"
it[subject] = "sample transaction $transactionReferenceDbit"
it[BankAccountTransactionsTable.amount] = amount.toString()
it[currency] = account.currency
it[date] = Instant.now().toEpochMilli()
it[accountServicerReference] = transactionReferenceDbit
it[BankAccountTransactionsTable.account] = account.id
it[direction] = "DBIT"
}
}
}
call.respond(object {})
}
/**
* Creates a new Ebics subscriber.
*/
post("/admin/ebics/subscribers") {
val body = call.receiveJson()
transaction {
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 all the Ebics subscribers' details.
*/
get("/admin/ebics/subscribers") {
val ret = AdminGetSubscribers()
transaction {
EbicsSubscriberEntity.all().forEach {
ret.subscribers.add(
EbicsSubscriberElement(
userID = it.userId,
partnerID = it.partnerId,
hostID = it.hostId
)
)
}
}
call.respond(ret)
return@get
}
/**
* Creates a new EBICS host.
*/
post("/admin/ebics/hosts") {
val req = call.receiveJson()
val pairA = CryptoUtil.generateRsaKeyPair(2048)
val pairB = CryptoUtil.generateRsaKeyPair(2048)
val pairC = CryptoUtil.generateRsaKeyPair(2048)
transaction {
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") {
val ebicsHosts = transaction {
EbicsHostEntity.all().map { it.hostId }
}
call.respond(EbicsHostsResponse(ebicsHosts))
}
/**
* Serves all the Ebics requests.
*/
post("/ebicsweb") {
call.ebicsweb()
}
}
}
logger.info("LibEuFin Sandbox running on port $port")
try {
server.start(wait = true)
} catch (e: BindException) {
logger.error(e.message)
exitProcess(1)
}
}