summaryrefslogtreecommitdiff
path: root/nexus/src/main/kotlin/tech/libeufin
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-06-19 12:21:07 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-06-19 12:21:07 +0530
commitf214ac079dae5a93e8716bf0349f8e70b5df0957 (patch)
tree7d0ae865c832b00c8593b2d1f8ee7f3550ce0b3a /nexus/src/main/kotlin/tech/libeufin
parent38fc8731ba5bfbae3a195ee8845aea9779a7802e (diff)
downloadlibeufin-f214ac079dae5a93e8716bf0349f8e70b5df0957.tar.gz
libeufin-f214ac079dae5a93e8716bf0349f8e70b5df0957.tar.bz2
libeufin-f214ac079dae5a93e8716bf0349f8e70b5df0957.zip
refactor, towards common interface for bank protocols
Diffstat (limited to 'nexus/src/main/kotlin/tech/libeufin')
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt20
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt3
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt25
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt792
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt101
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt47
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt36
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt (renamed from nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt)9
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt728
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt18
10 files changed, 973 insertions, 806 deletions
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt
new file mode 100644
index 00000000..cbe5ebb2
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt
@@ -0,0 +1,20 @@
+package tech.libeufin.nexus/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2020 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+interface BankConnectionProtocol \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
index 2b7475ab..ab07fde4 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
@@ -51,6 +51,7 @@ object TalerRequestedPayments : LongIdTable() {
class TalerRequestedPaymentEntity(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<TalerRequestedPaymentEntity>(TalerRequestedPayments)
+
var preparedPayment by PaymentInitiationEntity referencedOn TalerRequestedPayments.preparedPayment
var requestUId by TalerRequestedPayments.requestUId
var amount by TalerRequestedPayments.amount
@@ -148,6 +149,7 @@ object NexusBankTransactionsTable : LongIdTable() {
class NexusBankTransactionEntity(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<NexusBankTransactionEntity>(NexusBankTransactionsTable)
+
var currency by NexusBankTransactionsTable.currency
var amount by NexusBankTransactionsTable.amount
var status by NexusBankTransactionsTable.status
@@ -316,6 +318,7 @@ object TalerFacadeStateTable : IntIdTable() {
val reserveTransferLevel = text("reserveTransferLevel")
val intervalIncrement = text("intervalIncrement")
val facade = reference("facade", FacadesTable)
+
// highest ID seen in the raw transactions table.
val highestSeenMsgID = long("highestSeenMsgID").default(0)
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt
new file mode 100644
index 00000000..b5a40f35
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt
@@ -0,0 +1,25 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2020 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus
+
+import io.ktor.http.HttpStatusCode
+
+data class NexusError(val statusCode: HttpStatusCode, val reason: String) :
+ Exception("$reason (HTTP status $statusCode)") \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index 46ddafab..31e9e7bc 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -19,14 +19,6 @@
package tech.libeufin.nexus
-import com.fasterxml.jackson.core.util.DefaultIndenter
-import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
-import com.fasterxml.jackson.databind.JsonNode
-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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.core.subcommands
@@ -34,50 +26,11 @@ import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.prompt
-import io.ktor.application.ApplicationCall
-import io.ktor.application.ApplicationCallPipeline
-import io.ktor.application.call
-import io.ktor.application.install
-import io.ktor.client.HttpClient
-import io.ktor.features.CallLogging
-import io.ktor.features.ContentNegotiation
-import io.ktor.features.StatusPages
-import io.ktor.http.ContentType
-import io.ktor.http.HttpStatusCode
-import io.ktor.jackson.jackson
-import io.ktor.request.*
-import io.ktor.response.respond
-import io.ktor.response.respondBytes
-import io.ktor.response.respondText
-import io.ktor.routing.get
-import io.ktor.routing.post
-import io.ktor.routing.route
-import io.ktor.routing.routing
-import io.ktor.server.engine.embeddedServer
-import io.ktor.server.netty.Netty
-import io.ktor.utils.io.ByteReadChannel
-import io.ktor.utils.io.jvm.javaio.toByteReadChannel
-import io.ktor.utils.io.jvm.javaio.toInputStream
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.time.delay
-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 tech.libeufin.nexus.bankaccount.*
-import tech.libeufin.nexus.ebics.*
-import tech.libeufin.util.*
+import tech.libeufin.nexus.server.serverMain
import tech.libeufin.util.CryptoUtil.hashpw
-import java.io.PrintWriter
-import java.io.StringWriter
-import java.net.URLEncoder
-import java.time.Duration
-import java.util.zip.InflaterInputStream
-
-data class NexusError(val statusCode: HttpStatusCode, val reason: String) :
- Exception("$reason (HTTP status $statusCode)")
val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
@@ -122,746 +75,3 @@ fun main(args: Array<String>) {
.subcommands(Serve(), Superuser())
.main(args)
}
-
-suspend inline fun <reified T : Any> ApplicationCall.receiveJson(): T {
- try {
- return this.receive<T>()
- } catch (e: MissingKotlinParameterException) {
- throw NexusError(HttpStatusCode.BadRequest, "Missing value for ${e.pathReference}")
- } catch (e: MismatchedInputException) {
- throw NexusError(HttpStatusCode.BadRequest, "Invalid value for ${e.pathReference}")
- }
-}
-
-/**
- * Test HTTP basic auth. Throws error if password is wrong,
- * and makes sure that the user exists in the system.
- *
- * @param authorization the Authorization:-header line.
- * @return user id
- */
-fun authenticateRequest(request: ApplicationRequest): NexusUserEntity {
- val authorization = request.headers["Authorization"]
- val headerLine = if (authorization == null) throw NexusError(
- HttpStatusCode.BadRequest, "Authentication:-header line not found"
- ) else authorization
- val (username, password) = extractUserAndPassword(headerLine)
- val user = NexusUserEntity.find {
- NexusUsersTable.id eq username
- }.firstOrNull()
- if (user == null) {
- throw NexusError(HttpStatusCode.Unauthorized, "Unknown user '$username'")
- }
- if (!CryptoUtil.checkpw(password, user.passwordHash)) {
- throw NexusError(HttpStatusCode.Forbidden, "Wrong password")
- }
- return user
-}
-
-
-fun createLoopbackBankConnection(bankConnectionName: String, user: NexusUserEntity, data: JsonNode) {
- val bankConn = NexusBankConnectionEntity.new(bankConnectionName) {
- owner = user
- type = "loopback"
- }
- val bankAccount = jacksonObjectMapper().treeToValue(data, BankAccount::class.java)
- NexusBankAccountEntity.new(bankAccount.account) {
- iban = bankAccount.iban
- bankCode = bankAccount.bic
- accountHolder = bankAccount.holder
- defaultBankConnection = bankConn
- highestSeenBankMessageId = 0
- }
-}
-
-fun createEbicsBankConnection(bankConnectionName: String, user: NexusUserEntity, data: JsonNode) {
- val bankConn = NexusBankConnectionEntity.new(bankConnectionName) {
- owner = user
- type = "ebics"
- }
- val newTransportData = jacksonObjectMapper().treeToValue(data, EbicsNewTransport::class.java)
- val pairA = CryptoUtil.generateRsaKeyPair(2048)
- val pairB = CryptoUtil.generateRsaKeyPair(2048)
- val pairC = CryptoUtil.generateRsaKeyPair(2048)
- EbicsSubscriberEntity.new {
- ebicsURL = newTransportData.ebicsURL
- hostID = newTransportData.hostID
- partnerID = newTransportData.partnerID
- userID = newTransportData.userID
- systemID = newTransportData.systemID
- signaturePrivateKey = ExposedBlob((pairA.private.encoded))
- encryptionPrivateKey = ExposedBlob((pairB.private.encoded))
- authenticationPrivateKey = ExposedBlob((pairC.private.encoded))
- nexusBankConnection = bankConn
- ebicsIniState = EbicsInitState.NOT_SENT
- ebicsHiaState = EbicsInitState.NOT_SENT
- }
-}
-
-fun requireBankConnection(call: ApplicationCall, parameterKey: String): NexusBankConnectionEntity {
- val name = call.parameters[parameterKey]
- if (name == null) {
- throw NexusError(HttpStatusCode.InternalServerError, "no parameter for bank connection")
- }
- val conn = transaction { NexusBankConnectionEntity.findById(name) }
- if (conn == null) {
- throw NexusError(HttpStatusCode.NotFound, "bank connection '$name' not found")
- }
- return conn
-}
-
-fun ApplicationRequest.hasBody(): Boolean {
- if (this.isChunked()) {
- return true
- }
- val contentLengthHeaderStr = this.headers["content-length"]
- if (contentLengthHeaderStr != null) {
- try {
- val cl = contentLengthHeaderStr.toInt()
- return cl != 0
- } catch (e: NumberFormatException) {
- return false
- }
- }
- return false
-}
-
-inline fun reportAndIgnoreErrors(f: () -> Unit) {
- try {
- f()
- } catch (e: java.lang.Exception) {
- logger.error("ignoring exception", e)
- }
-}
-
-fun moreFrequentBackgroundTasks(httpClient: HttpClient) {
- GlobalScope.launch {
- while (true) {
- logger.debug("Running more frequent background jobs")
- reportAndIgnoreErrors {
- downloadTalerFacadesTransactions(
- httpClient,
- FetchSpecLatestJson(FetchLevel.ALL, null)
- )
- }
- // FIXME: should be done automatically after raw ingestion
- reportAndIgnoreErrors { ingestTalerTransactions() }
- reportAndIgnoreErrors { submitAllPaymentInitiations(httpClient) }
- logger.debug("More frequent background jobs done")
- delay(Duration.ofSeconds(1))
- }
- }
-}
-
-fun lessFrequentBackgroundTasks(httpClient: HttpClient) {
- GlobalScope.launch {
- while (true) {
- logger.debug("Less frequent background job")
- try {
- //downloadTalerFacadesTransactions(httpClient, "C53")
- } catch (e: Exception) {
- val sw = StringWriter()
- val pw = PrintWriter(sw)
- e.printStackTrace(pw)
- logger.info("==== Less frequent background task exception ====\n${sw}======")
- }
- delay(Duration.ofSeconds(10))
- }
- }
-}
-
-/** Crawls all the facades, and requests history for each of its creators. */
-suspend fun downloadTalerFacadesTransactions(httpClient: HttpClient, fetchSpec: FetchSpecJson) {
- val work = mutableListOf<Pair<String, String>>()
- transaction {
- TalerFacadeStateEntity.all().forEach {
- logger.debug("Fetching history for facade: ${it.id.value}, bank account: ${it.bankAccount}")
- work.add(Pair(it.facade.creator.id.value, it.bankAccount))
- }
- }
- work.forEach {
- fetchTransactionsInternal(
- client = httpClient,
- fetchSpec = fetchSpec,
- userId = it.first,
- accountid = it.second
- )
- }
-}
-
-fun <T> expectNonNull(param: T?): T {
- return param ?: throw EbicsProtocolError(
- HttpStatusCode.BadRequest,
- "Non-null value expected."
- )
-}
-
-fun ApplicationCall.expectUrlParameter(name: String): String {
- return this.request.queryParameters[name]
- ?: throw EbicsProtocolError(HttpStatusCode.BadRequest, "Parameter '$name' not provided in URI")
-}
-
-private suspend fun fetchTransactionsInternal(
- client: HttpClient,
- fetchSpec: FetchSpecJson,
- userId: String,
- accountid: String
-) {
- val res = transaction {
- val acct = NexusBankAccountEntity.findById(accountid)
- if (acct == null) {
- throw NexusError(
- HttpStatusCode.NotFound,
- "Account not found"
- )
- }
- val conn = acct.defaultBankConnection
- if (conn == null) {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "No default bank connection (explicit connection not yet supported)"
- )
- }
- return@transaction object {
- val connectionType = conn.type
- val connectionName = conn.id.value
- }
- }
- when (res.connectionType) {
- "ebics" -> {
- // FIXME(dold): Support fetching not only the latest transactions.
- // It's not clear what's the nicest way to support this.
- fetchEbicsBySpec(
- fetchSpec,
- client,
- res.connectionName
- )
- ingestBankMessagesIntoAccount(res.connectionName, accountid)
- }
- else -> throw NexusError(
- HttpStatusCode.BadRequest,
- "Connection type '${res.connectionType}' not implemented"
- )
- }
-}
-
-fun ensureNonNull(param: String?): String {
- return param ?: throw NexusError(
- HttpStatusCode.BadRequest, "Bad ID given: ${param}"
- )
-}
-
-fun ensureLong(param: String?): Long {
- val asString = ensureNonNull(param)
- return asString.toLongOrNull() ?: throw NexusError(
- HttpStatusCode.BadRequest, "Parameter is not a number: ${param}"
- )
-}
-
-/**
- * This helper function parses a Authorization:-header line, decode the credentials
- * and returns a pair made of username and hashed (sha256) password. The hashed value
- * will then be compared with the one kept into the database.
- */
-fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> {
- logger.debug("Authenticating: $authorizationHeader")
- val (username, password) = try {
- val split = authorizationHeader.split(" ")
- val plainUserAndPass = String(base64ToBytes(split[1]), Charsets.UTF_8)
- plainUserAndPass.split(":")
- } catch (e: java.lang.Exception) {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "invalid Authorization:-header received"
- )
- }
- return Pair(username, password)
-}
-
-fun serverMain(dbName: String) {
- dbCreateTables(dbName)
- val client = HttpClient {
- expectSuccess = false // this way, it does not throw exceptions on != 200 responses.
- }
- val server = embeddedServer(Netty, port = 5001) {
- install(CallLogging) {
- this.level = Level.DEBUG
- this.logger = tech.libeufin.nexus.logger
- }
- install(ContentNegotiation) {
- jackson {
- enable(SerializationFeature.INDENT_OUTPUT)
- setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
- indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
- indentObjectsWith(DefaultIndenter(" ", "\n"))
- })
- registerModule(KotlinModule(nullisSameAsDefault = true))
- }
- }
-
- install(StatusPages) {
- exception<NexusError> { cause ->
- logger.error("Exception while handling '${call.request.uri}'", cause)
- call.respondText(
- cause.reason,
- ContentType.Text.Plain,
- cause.statusCode
- )
- }
- exception<EbicsProtocolError> { cause ->
- logger.error("Exception while handling '${call.request.uri}'", cause)
- call.respondText(
- cause.reason,
- ContentType.Text.Plain,
- cause.statusCode
- )
- }
- exception<Exception> { cause ->
- logger.error("Uncaught exception while handling '${call.request.uri}'", cause)
- logger.error(cause.toString())
- 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()
- }
- }
-
- /**
- * Allow request body compression. Needed by Taler.
- */
- receivePipeline.intercept(ApplicationReceivePipeline.Before) {
- if (this.context.request.headers["Content-Encoding"] == "deflate") {
- logger.debug("About to inflate received data")
- val deflated = this.subject.value as ByteReadChannel
- val inflated = InflaterInputStream(deflated.toInputStream())
- proceedWith(ApplicationReceiveRequest(this.subject.typeInfo, inflated.toByteReadChannel()))
- return@intercept
- }
- proceed()
- return@intercept
- }
-
- lessFrequentBackgroundTasks(client)
- moreFrequentBackgroundTasks(client)
-
- routing {
- /**
- * Shows information about the requesting user.
- */
- get("/user") {
- val ret = transaction {
- val currentUser = authenticateRequest(call.request)
- UserResponse(
- username = currentUser.id.value,
- superuser = currentUser.superuser
- )
- }
- call.respond(HttpStatusCode.OK, ret)
- return@get
- }
-
- get("/users") {
- val users = transaction {
- transaction {
- NexusUserEntity.all().map {
- UserInfo(it.id.value, it.superuser)
- }
- }
- }
- val usersResp = UsersResponse(users)
- call.respond(HttpStatusCode.OK, usersResp)
- return@get
- }
-
- /**
- * Add a new ordinary user in the system (requires superuser privileges)
- */
- post("/users") {
- val body = call.receiveJson<User>()
- transaction {
- val currentUser = authenticateRequest(call.request)
- if (!currentUser.superuser) {
- throw NexusError(HttpStatusCode.Forbidden, "only superuser can do that")
- }
- NexusUserEntity.new(body.username) {
- passwordHash = hashpw(body.password)
- superuser = false
- }
- }
- call.respondText(
- "New NEXUS user registered. ID: ${body.username}",
- ContentType.Text.Plain,
- HttpStatusCode.OK
- )
- return@post
- }
-
- get("/bank-connection-protocols") {
- call.respond(HttpStatusCode.OK, BankProtocolsResponse(listOf("ebics", "loopback")))
- return@get
- }
-
- route("/bank-connection-protocols/ebics") {
- ebicsBankProtocolRoutes(client)
- }
-
- /**
- * Shows the bank accounts belonging to the requesting user.
- */
- get("/bank-accounts") {
- val bankAccounts = BankAccounts()
- transaction {
- authenticateRequest(call.request)
- // FIXME(dold): Only return accounts the user has at least read access to?
- NexusBankAccountEntity.all().forEach {
- bankAccounts.accounts.add(BankAccount(it.accountHolder, it.iban, it.bankCode, it.id.value))
- }
- }
- call.respond(bankAccounts)
- return@get
- }
-
- get("/bank-accounts/{accountid}") {
- val accountId = ensureNonNull(call.parameters["accountid"])
- val res = transaction {
- val user = authenticateRequest(call.request)
- val bankAccount = NexusBankAccountEntity.findById(accountId)
- if (bankAccount == null) {
- throw NexusError(HttpStatusCode.NotFound, "unknown bank account")
- }
- val holderEnc = URLEncoder.encode(bankAccount.accountHolder, "UTF-8")
- return@transaction object {
- val defaultBankConnection = bankAccount.defaultBankConnection?.id?.value
- val accountPaytoUri = "payto://iban/${bankAccount.iban}?receiver-name=$holderEnc"
- }
- }
- call.respond(res)
- }
- /**
- * Submit one particular payment to the bank.
- */
- post("/bank-accounts/{accountid}/payment-initiations/{uuid}/submit") {
- val uuid = ensureLong(call.parameters["uuid"])
- val accountId = ensureNonNull(call.parameters["accountid"])
- val res = transaction {
- authenticateRequest(call.request)
- }
- submitPaymentInitiation(client, uuid)
- call.respondText("Payment ${uuid} submitted")
- return@post
- }
-
- /**
- * Shows information about one particular payment initiation.
- */
- get("/bank-accounts/{accountid}/payment-initiations/{uuid}") {
- val res = transaction {
- val user = authenticateRequest(call.request)
- val paymentInitiation = getPaymentInitiation(ensureLong(call.parameters["uuid"]))
- return@transaction object {
- val paymentInitiation = paymentInitiation
- }
- }
- val sd = res.paymentInitiation.submissionDate
- call.respond(
- PaymentStatus(
- paymentInitiationId = res.paymentInitiation.id.value.toString(),
- submitted = res.paymentInitiation.submitted,
- creditorName = res.paymentInitiation.creditorName,
- creditorBic = res.paymentInitiation.creditorBic,
- creditorIban = res.paymentInitiation.creditorIban,
- amount = "${res.paymentInitiation.currency}:${res.paymentInitiation.sum}",
- subject = res.paymentInitiation.subject,
- submissionDate = if (sd != null) {
- importDateFromMillis(sd).toDashedDate()
- } else null,
- preparationDate = importDateFromMillis(res.paymentInitiation.preparationDate).toDashedDate()
- )
- )
- return@get
- }
-
- /**
- * Adds a new payment initiation.
- */
- post("/bank-accounts/{accountid}/payment-initiations") {
- val body = call.receive<CreatePaymentInitiationRequest>()
- val accountId = ensureNonNull(call.parameters["accountid"])
- val res = transaction {
- authenticateRequest(call.request)
- val bankAccount = NexusBankAccountEntity.findById(accountId)
- if (bankAccount == null) {
- throw NexusError(HttpStatusCode.NotFound, "unknown bank account")
- }
- val amount = parseAmount(body.amount)
- val paymentEntity = addPaymentInitiation(
- Pain001Data(
- creditorIban = body.iban,
- creditorBic = body.bic,
- creditorName = body.name,
- sum = amount.amount,
- currency = amount.currency,
- subject = body.subject
- ),
- bankAccount
- )
- return@transaction object {
- val uuid = paymentEntity.id.value
- }
- }
- call.respond(
- HttpStatusCode.OK,
- PaymentInitiationResponse(uuid = res.uuid.toString())
- )
- return@post
- }
-
- /**
- * Downloads new transactions from the bank.
- */
- post("/bank-accounts/{accountid}/fetch-transactions") {
- val accountid = call.parameters["accountid"]
- if (accountid == null) {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "Account id missing"
- )
- }
- val user = transaction { authenticateRequest(call.request) }
- val fetchSpec = if (call.request.hasBody()) {
- call.receive<FetchSpecJson>()
- } else {
- FetchSpecLatestJson(FetchLevel.ALL, null)
- }
- fetchTransactionsInternal(
- client,
- fetchSpec,
- user.id.value,
- accountid
- )
- call.respondText("Collection performed")
- return@post
- }
-
- /**
- * Asks list of transactions ALREADY downloaded from the bank.
- */
- get("/bank-accounts/{accountid}/transactions") {
- val bankAccount = expectNonNull(call.parameters["accountid"])
- val start = call.request.queryParameters["start"]
- val end = call.request.queryParameters["end"]
- val ret = Transactions()
- transaction {
- authenticateRequest(call.request).id.value
- NexusBankTransactionEntity.all().map {
- val tx = jacksonObjectMapper().readValue(it.transactionJson, BankTransaction::class.java)
- ret.transactions.add(tx)
- }
- }
- call.respond(ret)
- return@get
- }
-
- /**
- * Adds a new bank transport.
- */
- post("/bank-connections") {
- // user exists and is authenticated.
- val body = call.receive<CreateBankConnectionRequestJson>()
- transaction {
- val user = authenticateRequest(call.request)
- when (body) {
- is CreateBankConnectionFromBackupRequestJson -> {
- val type = body.data.get("type")
- if (type == null || !type.isTextual) {
- throw NexusError(HttpStatusCode.BadRequest, "backup needs type")
- }
- when (type.textValue()) {
- "ebics" -> {
- createEbicsBankConnectionFromBackup(body.name, user, body.passphrase, body.data)
- }
- else -> {
- throw NexusError(HttpStatusCode.BadRequest, "backup type not supported")
- }
- }
- }
- is CreateBankConnectionFromNewRequestJson -> {
- when (body.type) {
- "ebics" -> {
- createEbicsBankConnection(body.name, user, body.data)
- }
- "loopback" -> {
- createLoopbackBankConnection(body.name, user, body.data)
-
- }
- else -> {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "connection type ${body.type} not supported"
- )
- }
- }
- }
- }
- }
- call.respond(object {})
- }
-
- get("/bank-connections") {
- val connList = mutableListOf<BankConnectionInfo>()
- transaction {
- NexusBankConnectionEntity.all().forEach {
- connList.add(BankConnectionInfo(it.id.value, it.type))
- }
- }
- call.respond(BankConnectionsList(connList))
- }
-
- get("/bank-connections/{connid}") {
- val resp = transaction {
- val user = authenticateRequest(call.request)
- val conn = requireBankConnection(call, "connid")
- when (conn.type) {
- "ebics" -> {
- getEbicsConnectionDetails(conn)
- }
- else -> {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "bank connection is not of type 'ebics' (but '${conn.type}')"
- )
- }
- }
- }
- call.respond(resp)
- }
-
- post("/bank-connections/{connid}/export-backup") {
- transaction { authenticateRequest(call.request) }
- val body = call.receive<BackupRequestJson>()
- val response = run {
- val conn = requireBankConnection(call, "connid")
- when (conn.type) {
- "ebics" -> {
- exportEbicsKeyBackup(conn.id.value, body.passphrase)
- }
- else -> {
- throw NexusError(
- HttpStatusCode.BadRequest,
- "bank connection is not of type 'ebics' (but '${conn.type}')"
- )
- }
- }
- }
- call.response.headers.append("Content-Disposition", "attachment")
- call.respond(
- HttpStatusCode.OK,
- response
- )
- }
-
- post("/bank-connections/{connid}/connect") {
- val conn = transaction {
- authenticateRequest(call.request)
- requireBankConnection(call, "connid")
- }
- when (conn.type) {
- "ebics" -> {
- connectEbics(client, conn.id.value)
- }
- }
- call.respond(object {})
- }
-
- get("/bank-connections/{connid}/keyletter") {
- val conn = transaction {
- authenticateRequest(call.request)
- requireBankConnection(call, "connid")
- }
- when (conn.type) {
- "ebics" -> {
- val pdfBytes = getEbicsKeyLetterPdf(conn)
- call.respondBytes(pdfBytes, ContentType("application", "pdf"))
- }
- else -> throw NexusError(HttpStatusCode.NotImplemented, "keyletter not supporte dfor ${conn.type}")
- }
- }
-
- get("/bank-connections/{connid}/messages") {
- val ret = transaction {
- val list = BankMessageList()
- val conn = requireBankConnection(call, "connid")
- NexusBankMessageEntity.find { NexusBankMessagesTable.bankConnection eq conn.id }.map {
- list.bankMessages.add(BankMessageInfo(it.messageId, it.code, it.message.bytes.size.toLong()))
- }
- list
- }
- call.respond(ret)
- }
-
- get("/bank-connections/{connid}/messages/{msgid}") {
- val ret = transaction {
- val msgid = call.parameters["msgid"]
- if (msgid == null || msgid == "") {
- throw NexusError(HttpStatusCode.BadRequest, "missing or invalid message ID")
- }
- val msg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgid }.firstOrNull()
- if (msg == null) {
- throw NexusError(HttpStatusCode.NotFound, "bank message not found")
- }
- return@transaction object {
- val msgContent = msg.message.bytes
- }
- }
- call.respondBytes(ret.msgContent, ContentType("application", "xml"))
- }
-
- post("/facades") {
- val body = call.receive<FacadeInfo>()
- val newFacade = transaction {
- val user = authenticateRequest(call.request)
- FacadeEntity.new(body.name) {
- type = body.type
- creator = user
- }
- }
- transaction {
- TalerFacadeStateEntity.new {
- bankAccount = body.config.bankAccount
- bankConnection = body.config.bankConnection
- intervalIncrement = body.config.intervalIncremental
- reserveTransferLevel = body.config.reserveTransferLevel
- facade = newFacade
- }
- }
- call.respondText("Facade created")
- return@post
- }
-
- route("/bank-connections/{connid}/ebics") {
- ebicsBankConnectionRoutes(client)
- }
-
- route("/facades/{fcid}/taler") {
- talerFacadeRoutes(this, client)
- }
- /**
- * Hello endpoint.
- */
- get("/") {
- call.respondText("Hello, this is Nexus.\n")
- return@get
- }
- }
- }
- logger.info("Up and running")
- server.start(wait = true)
-}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt
new file mode 100644
index 00000000..c530adf6
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt
@@ -0,0 +1,101 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2020 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus
+
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.time.delay
+import org.jetbrains.exposed.sql.transactions.transaction
+import tech.libeufin.nexus.bankaccount.fetchTransactionsInternal
+import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations
+import tech.libeufin.nexus.server.FetchLevel
+import tech.libeufin.nexus.server.FetchSpecJson
+import tech.libeufin.nexus.server.FetchSpecLatestJson
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.time.Duration
+
+/** Crawls all the facades, and requests history for each of its creators. */
+suspend fun downloadTalerFacadesTransactions(httpClient: HttpClient, fetchSpec: FetchSpecJson) {
+ val work = mutableListOf<Pair<String, String>>()
+ transaction {
+ TalerFacadeStateEntity.all().forEach {
+ logger.debug("Fetching history for facade: ${it.id.value}, bank account: ${it.bankAccount}")
+ work.add(Pair(it.facade.creator.id.value, it.bankAccount))
+ }
+ }
+ work.forEach {
+ fetchTransactionsInternal(
+ client = httpClient,
+ fetchSpec = fetchSpec,
+ userId = it.first,
+ accountid = it.second
+ )
+ }
+}
+
+
+private inline fun reportAndIgnoreErrors(f: () -> Unit) {
+ try {
+ f()
+ } catch (e: java.lang.Exception) {
+ logger.error("ignoring exception", e)
+ }
+}
+
+fun moreFrequentBackgroundTasks(httpClient: HttpClient) {
+ GlobalScope.launch {
+ while (true) {
+ logger.debug("Running more frequent background jobs")
+ reportAndIgnoreErrors {
+ downloadTalerFacadesTransactions(
+ httpClient,
+ FetchSpecLatestJson(
+ FetchLevel.ALL,
+ null
+ )
+ )
+ }
+ // FIXME: should be done automatically after raw ingestion
+ reportAndIgnoreErrors { ingestTalerTransactions() }
+ reportAndIgnoreErrors { submitAllPaymentInitiations(httpClient) }
+ logger.debug("More frequent background jobs done")
+ delay(Duration.ofSeconds(1))
+ }
+ }
+}
+
+fun lessFrequentBackgroundTasks(httpClient: HttpClient) {
+ GlobalScope.launch {
+ while (true) {
+ logger.debug("Less frequent background job")
+ try {
+ //downloadTalerFacadesTransactions(httpClient, "C53")
+ } catch (e: Exception) {
+ val sw = StringWriter()
+ val pw = PrintWriter(sw)
+ e.printStackTrace(pw)
+ logger.info("==== Less frequent background task exception ====\n${sw}======")
+ }
+ delay(Duration.ofSeconds(10))
+ }
+ }
+}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
index d35e9e63..bf672ce4 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
@@ -27,7 +27,10 @@ import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import org.w3c.dom.Document
import tech.libeufin.nexus.*
+import tech.libeufin.nexus.ebics.fetchEbicsBySpec
import tech.libeufin.nexus.ebics.submitEbicsPaymentInitiation
+import tech.libeufin.nexus.server.FetchSpecJson
+import tech.libeufin.nexus.server.Pain001Data
import tech.libeufin.util.XMLUtil
import java.time.Instant
@@ -229,3 +232,47 @@ fun addPaymentInitiation(paymentData: Pain001Data, debitorAccount: NexusBankAcco
}
}
}
+
+suspend fun fetchTransactionsInternal(
+ client: HttpClient,
+ fetchSpec: FetchSpecJson,
+ userId: String,
+ accountid: String
+) {
+ val res = transaction {
+ val acct = NexusBankAccountEntity.findById(accountid)
+ if (acct == null) {
+ throw NexusError(
+ HttpStatusCode.NotFound,
+ "Account not found"
+ )
+ }
+ val conn = acct.defaultBankConnection
+ if (conn == null) {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "No default bank connection (explicit connection not yet supported)"
+ )
+ }
+ return@transaction object {
+ val connectionType = conn.type
+ val connectionName = conn.id.value
+ }
+ }
+ when (res.connectionType) {
+ "ebics" -> {
+ // FIXME(dold): Support fetching not only the latest transactions.
+ // It's not clear what's the nicest way to support this.
+ fetchEbicsBySpec(
+ fetchSpec,
+ client,
+ res.connectionName
+ )
+ ingestBankMessagesIntoAccount(res.connectionName, accountid)
+ }
+ else -> throw NexusError(
+ HttpStatusCode.BadRequest,
+ "Connection type '${res.connectionType}' not implemented"
+ )
+ }
+}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt
index 4d4c27f6..022aa64f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt
@@ -44,6 +44,7 @@ import org.jetbrains.exposed.sql.statements.api.ExposedBlob
import org.jetbrains.exposed.sql.transactions.transaction
import tech.libeufin.nexus.*
import tech.libeufin.nexus.logger
+import tech.libeufin.nexus.server.*
import tech.libeufin.util.*
import tech.libeufin.util.ebics_h004.EbicsTypes
import tech.libeufin.util.ebics_h004.HTDResponseOrderData
@@ -422,7 +423,12 @@ fun Route.ebicsBankConnectionRoutes(client: HttpClient) {
is EbicsDownloadBankErrorResult -> {
call.respond(
HttpStatusCode.BadGateway,
- EbicsErrorJson(EbicsErrorDetailJson("bankError", response.returnCode.errorCode))
+ EbicsErrorJson(
+ EbicsErrorDetailJson(
+ "bankError",
+ response.returnCode.errorCode
+ )
+ )
)
}
}
@@ -655,7 +661,8 @@ suspend fun submitEbicsPaymentInitiation(httpClient: HttpClient, paymentInitiati
subject = paymentInitiation.subject,
instructionId = paymentInitiation.instructionId,
endToEndId = paymentInitiation.endToEndId
- ))
+ )
+ )
object {
val subscriberDetails = subscriberDetails
val painMessage = painMessage
@@ -674,3 +681,28 @@ suspend fun submitEbicsPaymentInitiation(httpClient: HttpClient, paymentInitiati
paymentInitiation.submitted = true
}
}
+
+
+fun createEbicsBankConnection(bankConnectionName: String, user: NexusUserEntity, data: JsonNode) {
+ val bankConn = NexusBankConnectionEntity.new(bankConnectionName) {
+ owner = user
+ type = "ebics"
+ }
+ val newTransportData = jacksonObjectMapper().treeToValue(data, EbicsNewTransport::class.java)
+ val pairA = CryptoUtil.generateRsaKeyPair(2048)
+ val pairB = CryptoUtil.generateRsaKeyPair(2048)
+ val pairC = CryptoUtil.generateRsaKeyPair(2048)
+ EbicsSubscriberEntity.new {
+ ebicsURL = newTransportData.ebicsURL
+ hostID = newTransportData.hostID
+ partnerID = newTransportData.partnerID
+ userID = newTransportData.userID
+ systemID = newTransportData.systemID
+ signaturePrivateKey = ExposedBlob((pairA.private.encoded))
+ encryptionPrivateKey = ExposedBlob((pairB.private.encoded))
+ authenticationPrivateKey = ExposedBlob((pairC.private.encoded))
+ nexusBankConnection = bankConn
+ ebicsIniState = EbicsInitState.NOT_SENT
+ ebicsHiaState = EbicsInitState.NOT_SENT
+ }
+}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
index d3bf9c0b..0301a059 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
@@ -17,16 +17,16 @@
* <http://www.gnu.org/licenses/>
*/
-package tech.libeufin.nexus
+package tech.libeufin.nexus.server
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeName
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.databind.JsonNode
+import tech.libeufin.nexus.BankTransaction
import tech.libeufin.util.*
import java.time.LocalDate
-import java.time.LocalDateTime
data class BackupRequestJson(
val passphrase: String
@@ -135,7 +135,7 @@ enum class FetchLevel(@get:JsonValue val jsonName: String) {
@JsonSubTypes(
JsonSubTypes.Type(value = FetchSpecLatestJson::class, name = "latest"),
JsonSubTypes.Type(value = FetchSpecAllJson::class, name = "all"),
- JsonSubTypes.Type(value = FetchSpecPreviousDaysJson::class, name = "previous-days") ,
+ JsonSubTypes.Type(value = FetchSpecPreviousDaysJson::class, name = "previous-days"),
JsonSubTypes.Type(value = FetchSpecSinceLastJson::class, name = "since-last")
)
abstract class FetchSpecJson(
@@ -145,10 +145,13 @@ abstract class FetchSpecJson(
@JsonTypeName("latest")
class FetchSpecLatestJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection)
+
@JsonTypeName("all")
class FetchSpecAllJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection)
+
@JsonTypeName("since-last")
class FetchSpecSinceLastJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection)
+
@JsonTypeName("previous-days")
class FetchSpecPreviousDaysJson(level: FetchLevel, bankConnection: String?, val number: Int) :
FetchSpecJson(level, bankConnection)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
new file mode 100644
index 00000000..6e4a4226
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
@@ -0,0 +1,728 @@
+package tech.libeufin.nexus.server
+
+import com.fasterxml.jackson.core.util.DefaultIndenter
+import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
+import com.fasterxml.jackson.databind.JsonNode
+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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.ktor.application.ApplicationCall
+import io.ktor.application.ApplicationCallPipeline
+import io.ktor.application.call
+import io.ktor.application.install
+import io.ktor.client.HttpClient
+import io.ktor.features.CallLogging
+import io.ktor.features.ContentNegotiation
+import io.ktor.features.StatusPages
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.jackson.jackson
+import io.ktor.request.*
+import io.ktor.response.respond
+import io.ktor.response.respondBytes
+import io.ktor.response.respondText
+import io.ktor.routing.get
+import io.ktor.routing.post
+import io.ktor.routing.route
+import io.ktor.routing.routing
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.netty.Netty
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.jvm.javaio.toByteReadChannel
+import io.ktor.utils.io.jvm.javaio.toInputStream
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.slf4j.event.Level
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.bankaccount.addPaymentInitiation
+import tech.libeufin.nexus.bankaccount.fetchTransactionsInternal
+import tech.libeufin.nexus.bankaccount.getPaymentInitiation
+import tech.libeufin.nexus.bankaccount.submitPaymentInitiation
+import tech.libeufin.nexus.ebics.*
+import tech.libeufin.util.*
+import tech.libeufin.util.logger
+import java.net.URLEncoder
+import java.util.zip.InflaterInputStream
+
+
+fun ensureNonNull(param: String?): String {
+ return param ?: throw NexusError(
+ HttpStatusCode.BadRequest, "Bad ID given: ${param}"
+ )
+}
+
+fun ensureLong(param: String?): Long {
+ val asString = ensureNonNull(param)
+ return asString.toLongOrNull() ?: throw NexusError(
+ HttpStatusCode.BadRequest, "Parameter is not a number: ${param}"
+ )
+}
+
+fun <T> expectNonNull(param: T?): T {
+ return param ?: throw EbicsProtocolError(
+ HttpStatusCode.BadRequest,
+ "Non-null value expected."
+ )
+}
+
+/**
+ * This helper function parses a Authorization:-header line, decode the credentials
+ * and returns a pair made of username and hashed (sha256) password. The hashed value
+ * will then be compared with the one kept into the database.
+ */
+fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> {
+ logger.debug("Authenticating: $authorizationHeader")
+ val (username, password) = try {
+ val split = authorizationHeader.split(" ")
+ val plainUserAndPass = String(base64ToBytes(split[1]), Charsets.UTF_8)
+ plainUserAndPass.split(":")
+ } catch (e: java.lang.Exception) {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "invalid Authorization:-header received"
+ )
+ }
+ return Pair(username, password)
+}
+
+
+/**
+ * Test HTTP basic auth. Throws error if password is wrong,
+ * and makes sure that the user exists in the system.
+ *
+ * @param authorization the Authorization:-header line.
+ * @return user id
+ */
+fun authenticateRequest(request: ApplicationRequest): NexusUserEntity {
+ val authorization = request.headers["Authorization"]
+ val headerLine = if (authorization == null) throw NexusError(
+ HttpStatusCode.BadRequest, "Authentication:-header line not found"
+ ) else authorization
+ val (username, password) = extractUserAndPassword(headerLine)
+ val user = NexusUserEntity.find {
+ NexusUsersTable.id eq username
+ }.firstOrNull()
+ if (user == null) {
+ throw NexusError(HttpStatusCode.Unauthorized, "Unknown user '$username'")
+ }
+ if (!CryptoUtil.checkpw(password, user.passwordHash)) {
+ throw NexusError(HttpStatusCode.Forbidden, "Wrong password")
+ }
+ return user
+}
+
+
+fun ApplicationRequest.hasBody(): Boolean {
+ if (this.isChunked()) {
+ return true
+ }
+ val contentLengthHeaderStr = this.headers["content-length"]
+ if (contentLengthHeaderStr != null) {
+ try {
+ val cl = contentLengthHeaderStr.toInt()
+ return cl != 0
+ } catch (e: NumberFormatException) {
+ return false
+ }
+ }
+ return false
+}
+
+fun ApplicationCall.expectUrlParameter(name: String): String {
+ return this.request.queryParameters[name]
+ ?: throw EbicsProtocolError(HttpStatusCode.BadRequest, "Parameter '$name' not provided in URI")
+}
+
+suspend inline fun <reified T : Any> ApplicationCall.receiveJson(): T {
+ try {
+ return this.receive<T>()
+ } catch (e: MissingKotlinParameterException) {
+ throw NexusError(HttpStatusCode.BadRequest, "Missing value for ${e.pathReference}")
+ } catch (e: MismatchedInputException) {
+ throw NexusError(HttpStatusCode.BadRequest, "Invalid value for ${e.pathReference}")
+ }
+}
+
+
+fun createLoopbackBankConnection(bankConnectionName: String, user: NexusUserEntity, data: JsonNode) {
+ val bankConn = NexusBankConnectionEntity.new(bankConnectionName) {
+ owner = user
+ type = "loopback"
+ }
+ val bankAccount = jacksonObjectMapper().treeToValue(data, BankAccount::class.java)
+ NexusBankAccountEntity.new(bankAccount.account) {
+ iban = bankAccount.iban
+ bankCode = bankAccount.bic
+ accountHolder = bankAccount.holder
+ defaultBankConnection = bankConn
+ highestSeenBankMessageId = 0
+ }
+}
+
+
+fun requireBankConnection(call: ApplicationCall, parameterKey: String): NexusBankConnectionEntity {
+ val name = call.parameters[parameterKey]
+ if (name == null) {
+ throw NexusError(HttpStatusCode.InternalServerError, "no parameter for bank connection")
+ }
+ val conn = transaction { NexusBankConnectionEntity.findById(name) }
+ if (conn == null) {
+ throw NexusError(HttpStatusCode.NotFound, "bank connection '$name' not found")
+ }
+ return conn
+}
+
+
+fun serverMain(dbName: String) {
+ dbCreateTables(dbName)
+ val client = HttpClient {
+ expectSuccess = false // this way, it does not throw exceptions on != 200 responses.
+ }
+ val server = embeddedServer(Netty, port = 5001) {
+ install(CallLogging) {
+ this.level = Level.DEBUG
+ this.logger = tech.libeufin.nexus.logger
+ }
+ install(ContentNegotiation) {
+ jackson {
+ enable(SerializationFeature.INDENT_OUTPUT)
+ setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
+ indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
+ indentObjectsWith(DefaultIndenter(" ", "\n"))
+ })
+ registerModule(KotlinModule(nullisSameAsDefault = true))
+ }
+ }
+
+ install(StatusPages) {
+ exception<NexusError> { cause ->
+ logger.error("Exception while handling '${call.request.uri}'", cause)
+ call.respondText(
+ cause.reason,
+ ContentType.Text.Plain,
+ cause.statusCode
+ )
+ }
+ exception<EbicsProtocolError> { cause ->
+ logger.error("Exception while handling '${call.request.uri}'", cause)
+ call.respondText(
+ cause.reason,
+ ContentType.Text.Plain,
+ cause.statusCode
+ )
+ }
+ exception<Exception> { cause ->
+ logger.error("Uncaught exception while handling '${call.request.uri}'", cause)
+ logger.error(cause.toString())
+ 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()
+ }
+ }
+
+ /**
+ * Allow request body compression. Needed by Taler.
+ */
+
+ /**
+ * Allow request body compression. Needed by Taler.
+ */
+ receivePipeline.intercept(ApplicationReceivePipeline.Before) {
+ if (this.context.request.headers["Content-Encoding"] == "deflate") {
+ logger.debug("About to inflate received data")
+ val deflated = this.subject.value as ByteReadChannel
+ val inflated = InflaterInputStream(deflated.toInputStream())
+ proceedWith(ApplicationReceiveRequest(this.subject.typeInfo, inflated.toByteReadChannel()))
+ return@intercept
+ }
+ proceed()
+ return@intercept
+ }
+
+ lessFrequentBackgroundTasks(client)
+ moreFrequentBackgroundTasks(client)
+
+ routing {
+ /**
+ * Shows information about the requesting user.
+ */
+ /**
+ * Shows information about the requesting user.
+ */
+ get("/user") {
+ val ret = transaction {
+ val currentUser = authenticateRequest(call.request)
+ UserResponse(
+ username = currentUser.id.value,
+ superuser = currentUser.superuser
+ )
+ }
+ call.respond(HttpStatusCode.OK, ret)
+ return@get
+ }
+
+ get("/users") {
+ val users = transaction {
+ transaction {
+ NexusUserEntity.all().map {
+ UserInfo(it.id.value, it.superuser)
+ }
+ }
+ }
+ val usersResp = UsersResponse(users)
+ call.respond(HttpStatusCode.OK, usersResp)
+ return@get
+ }
+
+ /**
+ * Add a new ordinary user in the system (requires superuser privileges)
+ */
+
+ /**
+ * Add a new ordinary user in the system (requires superuser privileges)
+ */
+ post("/users") {
+ val body = call.receiveJson<User>()
+ transaction {
+ val currentUser = authenticateRequest(call.request)
+ if (!currentUser.superuser) {
+ throw NexusError(HttpStatusCode.Forbidden, "only superuser can do that")
+ }
+ NexusUserEntity.new(body.username) {
+ passwordHash = CryptoUtil.hashpw(body.password)
+ superuser = false
+ }
+ }
+ call.respondText(
+ "New NEXUS user registered. ID: ${body.username}",
+ ContentType.Text.Plain,
+ HttpStatusCode.OK
+ )
+ return@post
+ }
+
+ get("/bank-connection-protocols") {
+ call.respond(
+ HttpStatusCode.OK,
+ BankProtocolsResponse(listOf("ebics", "loopback"))
+ )
+ return@get
+ }
+
+ route("/bank-connection-protocols/ebics") {
+ ebicsBankProtocolRoutes(client)
+ }
+
+ /**
+ * Shows the bank accounts belonging to the requesting user.
+ */
+
+ /**
+ * Shows the bank accounts belonging to the requesting user.
+ */
+ get("/bank-accounts") {
+ val bankAccounts = BankAccounts()
+ transaction {
+ authenticateRequest(call.request)
+ // FIXME(dold): Only return accounts the user has at least read access to?
+ NexusBankAccountEntity.all().forEach {
+ bankAccounts.accounts.add(
+ BankAccount(
+ it.accountHolder,
+ it.iban,
+ it.bankCode,
+ it.id.value
+ )
+ )
+ }
+ }
+ call.respond(bankAccounts)
+ return@get
+ }
+
+ get("/bank-accounts/{accountid}") {
+ val accountId = ensureNonNull(call.parameters["accountid"])
+ val res = transaction {
+ val user = authenticateRequest(call.request)
+ val bankAccount = NexusBankAccountEntity.findById(accountId)
+ if (bankAccount == null) {
+ throw NexusError(HttpStatusCode.NotFound, "unknown bank account")
+ }
+ val holderEnc = URLEncoder.encode(bankAccount.accountHolder, "UTF-8")
+ return@transaction object {
+ val defaultBankConnection = bankAccount.defaultBankConnection?.id?.value
+ val accountPaytoUri = "payto://iban/${bankAccount.iban}?receiver-name=$holderEnc"
+ }
+ }
+ call.respond(res)
+ }
+ /**
+ * Submit one particular payment to the bank.
+ */
+ /**
+ * Submit one particular payment to the bank.
+ */
+ post("/bank-accounts/{accountid}/payment-initiations/{uuid}/submit") {
+ val uuid = ensureLong(call.parameters["uuid"])
+ val accountId = ensureNonNull(call.parameters["accountid"])
+ val res = transaction {
+ authenticateRequest(call.request)
+ }
+ submitPaymentInitiation(client, uuid)
+ call.respondText("Payment ${uuid} submitted")
+ return@post
+ }
+
+ /**
+ * Shows information about one particular payment initiation.
+ */
+
+ /**
+ * Shows information about one particular payment initiation.
+ */
+ get("/bank-accounts/{accountid}/payment-initiations/{uuid}") {
+ val res = transaction {
+ val user = authenticateRequest(call.request)
+ val paymentInitiation = getPaymentInitiation(ensureLong(call.parameters["uuid"]))
+ return@transaction object {
+ val paymentInitiation = paymentInitiation
+ }
+ }
+ val sd = res.paymentInitiation.submissionDate
+ call.respond(
+ PaymentStatus(
+ paymentInitiationId = res.paymentInitiation.id.value.toString(),
+ submitted = res.paymentInitiation.submitted,
+ creditorName = res.paymentInitiation.creditorName,
+ creditorBic = res.paymentInitiation.creditorBic,
+ creditorIban = res.paymentInitiation.creditorIban,
+ amount = "${res.paymentInitiation.currency}:${res.paymentInitiation.sum}",
+ subject = res.paymentInitiation.subject,
+ submissionDate = if (sd != null) {
+ importDateFromMillis(sd).toDashedDate()
+ } else null,
+ preparationDate = importDateFromMillis(res.paymentInitiation.preparationDate).toDashedDate()
+ )
+ )
+ return@get
+ }
+
+ /**
+ * Adds a new payment initiation.
+ */
+
+ /**
+ * Adds a new payment initiation.
+ */
+ post("/bank-accounts/{accountid}/payment-initiations") {
+ val body = call.receive<CreatePaymentInitiationRequest>()
+ val accountId = ensureNonNull(call.parameters["accountid"])
+ val res = transaction {
+ authenticateRequest(call.request)
+ val bankAccount = NexusBankAccountEntity.findById(accountId)
+ if (bankAccount == null) {
+ throw NexusError(HttpStatusCode.NotFound, "unknown bank account")
+ }
+ val amount = parseAmount(body.amount)
+ val paymentEntity = addPaymentInitiation(
+ Pain001Data(
+ creditorIban = body.iban,
+ creditorBic = body.bic,
+ creditorName = body.name,
+ sum = amount.amount,
+ currency = amount.currency,
+ subject = body.subject
+ ),
+ bankAccount
+ )
+ return@transaction object {
+ val uuid = paymentEntity.id.value
+ }
+ }
+ call.respond(
+ HttpStatusCode.OK,
+ PaymentInitiationResponse(uuid = res.uuid.toString())
+ )
+ return@post
+ }
+
+ /**
+ * Downloads new transactions from the bank.
+ */
+
+ /**
+ * Downloads new transactions from the bank.
+ */
+ post("/bank-accounts/{accountid}/fetch-transactions") {
+ val accountid = call.parameters["accountid"]
+ if (accountid == null) {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "Account id missing"
+ )
+ }
+ val user = transaction { authenticateRequest(call.request) }
+ val fetchSpec = if (call.request.hasBody()) {
+ call.receive<FetchSpecJson>()
+ } else {
+ FetchSpecLatestJson(
+ FetchLevel.ALL,
+ null
+ )
+ }
+ fetchTransactionsInternal(
+ client,
+ fetchSpec,
+ user.id.value,
+ accountid
+ )
+ call.respondText("Collection performed")
+ return@post
+ }
+
+ /**
+ * Asks list of transactions ALREADY downloaded from the bank.
+ */
+
+ /**
+ * Asks list of transactions ALREADY downloaded from the bank.
+ */
+ get("/bank-accounts/{accountid}/transactions") {
+ val bankAccount = expectNonNull(call.parameters["accountid"])
+ val start = call.request.queryParameters["start"]
+ val end = call.request.queryParameters["end"]
+ val ret = Transactions()
+ transaction {
+ authenticateRequest(call.request).id.value
+ NexusBankTransactionEntity.all().map {
+ val tx = jacksonObjectMapper().readValue(it.transactionJson, BankTransaction::class.java)
+ ret.transactions.add(tx)
+ }
+ }
+ call.respond(ret)
+ return@get
+ }
+
+ /**
+ * Adds a new bank transport.
+ */
+
+ /**
+ * Adds a new bank transport.
+ */
+ post("/bank-connections") {
+ // user exists and is authenticated.
+ val body = call.receive<CreateBankConnectionRequestJson>()
+ transaction {
+ val user = authenticateRequest(call.request)
+ when (body) {
+ is CreateBankConnectionFromBackupRequestJson -> {
+ val type = body.data.get("type")
+ if (type == null || !type.isTextual) {
+ throw NexusError(HttpStatusCode.BadRequest, "backup needs type")
+ }
+ when (type.textValue()) {
+ "ebics" -> {
+ createEbicsBankConnectionFromBackup(body.name, user, body.passphrase, body.data)
+ }
+ else -> {
+ throw NexusError(HttpStatusCode.BadRequest, "backup type not supported")
+ }
+ }
+ }
+ is CreateBankConnectionFromNewRequestJson -> {
+ when (body.type) {
+ "ebics" -> {
+ createEbicsBankConnection(body.name, user, body.data)
+ }
+ "loopback" -> {
+ createLoopbackBankConnection(body.name, user, body.data)
+
+ }
+ else -> {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "connection type ${body.type} not supported"
+ )
+ }
+ }
+ }
+ }
+ }
+ call.respond(object {})
+ }
+
+ get("/bank-connections") {
+ val connList = mutableListOf<BankConnectionInfo>()
+ transaction {
+ NexusBankConnectionEntity.all().forEach {
+ connList.add(
+ BankConnectionInfo(
+ it.id.value,
+ it.type
+ )
+ )
+ }
+ }
+ call.respond(BankConnectionsList(connList))
+ }
+
+ get("/bank-connections/{connid}") {
+ val resp = transaction {
+ val user = authenticateRequest(call.request)
+ val conn = requireBankConnection(call, "connid")
+ when (conn.type) {
+ "ebics" -> {
+ getEbicsConnectionDetails(conn)
+ }
+ else -> {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "bank connection is not of type 'ebics' (but '${conn.type}')"
+ )
+ }
+ }
+ }
+ call.respond(resp)
+ }
+
+ post("/bank-connections/{connid}/export-backup") {
+ transaction { authenticateRequest(call.request) }
+ val body = call.receive<BackupRequestJson>()
+ val response = run {
+ val conn = requireBankConnection(call, "connid")
+ when (conn.type) {
+ "ebics" -> {
+ exportEbicsKeyBackup(conn.id.value, body.passphrase)
+ }
+ else -> {
+ throw NexusError(
+ HttpStatusCode.BadRequest,
+ "bank connection is not of type 'ebics' (but '${conn.type}')"
+ )
+ }
+ }
+ }
+ call.response.headers.append("Content-Disposition", "attachment")
+ call.respond(
+ HttpStatusCode.OK,
+ response
+ )
+ }
+
+ post("/bank-connections/{connid}/connect") {
+ val conn = transaction {
+ authenticateRequest(call.request)
+ requireBankConnection(call, "connid")
+ }
+ when (conn.type) {
+ "ebics" -> {
+ connectEbics(client, conn.id.value)
+ }
+ }
+ call.respond(object {})
+ }
+
+ get("/bank-connections/{connid}/keyletter") {
+ val conn = transaction {
+ authenticateRequest(call.request)
+ requireBankConnection(call, "connid")
+ }
+ when (conn.type) {
+ "ebics" -> {
+ val pdfBytes = getEbicsKeyLetterPdf(conn)
+ call.respondBytes(pdfBytes, ContentType("application", "pdf"))
+ }
+ else -> throw NexusError(HttpStatusCode.NotImplemented, "keyletter not supporte dfor ${conn.type}")
+ }
+ }
+
+ get("/bank-connections/{connid}/messages") {
+ val ret = transaction {
+ val list = BankMessageList()
+ val conn = requireBankConnection(call, "connid")
+ NexusBankMessageEntity.find { NexusBankMessagesTable.bankConnection eq conn.id }.map {
+ list.bankMessages.add(
+ BankMessageInfo(
+ it.messageId,
+ it.code,
+ it.message.bytes.size.toLong()
+ )
+ )
+ }
+ list
+ }
+ call.respond(ret)
+ }
+
+ get("/bank-connections/{connid}/messages/{msgid}") {
+ val ret = transaction {
+ val msgid = call.parameters["msgid"]
+ if (msgid == null || msgid == "") {
+ throw NexusError(HttpStatusCode.BadRequest, "missing or invalid message ID")
+ }
+ val msg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgid }.firstOrNull()
+ if (msg == null) {
+ throw NexusError(HttpStatusCode.NotFound, "bank message not found")
+ }
+ return@transaction object {
+ val msgContent = msg.message.bytes
+ }
+ }
+ call.respondBytes(ret.msgContent, ContentType("application", "xml"))
+ }
+
+ post("/facades") {
+ val body = call.receive<FacadeInfo>()
+ val newFacade = transaction {
+ val user = authenticateRequest(call.request)
+ FacadeEntity.new(body.name) {
+ type = body.type
+ creator = user
+ }
+ }
+ transaction {
+ TalerFacadeStateEntity.new {
+ bankAccount = body.config.bankAccount
+ bankConnection = body.config.bankConnection
+ intervalIncrement = body.config.intervalIncremental
+ reserveTransferLevel = body.config.reserveTransferLevel
+ facade = newFacade
+ }
+ }
+ call.respondText("Facade created")
+ return@post
+ }
+
+ route("/bank-connections/{connid}/ebics") {
+ ebicsBankConnectionRoutes(client)
+ }
+
+ route("/facades/{fcid}/taler") {
+ talerFacadeRoutes(this, client)
+ }
+ /**
+ * Hello endpoint.
+ */
+ /**
+ * Hello endpoint.
+ */
+ get("/") {
+ call.respondText("Hello, this is Nexus.\n")
+ return@get
+ }
+ }
+ }
+ logger.info("Up and running")
+ server.start(wait = true)
+}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt
index 3b79bac2..97e48af4 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt
@@ -39,7 +39,14 @@ import org.jetbrains.exposed.dao.id.IdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import tech.libeufin.nexus.bankaccount.addPaymentInitiation
-import tech.libeufin.util.*
+import tech.libeufin.nexus.server.Pain001Data
+import tech.libeufin.nexus.server.authenticateRequest
+import tech.libeufin.nexus.server.expectNonNull
+import tech.libeufin.nexus.server.expectUrlParameter
+import tech.libeufin.util.CryptoUtil
+import tech.libeufin.util.EbicsProtocolError
+import tech.libeufin.util.parseAmount
+import tech.libeufin.util.parsePayto
import kotlin.math.abs
import kotlin.math.min
@@ -188,15 +195,6 @@ fun extractReservePubFromSubject(rawSubject: String): String? {
return result.value.toUpperCase()
}
-/**
- * Tries to extract a valid wire transfer id from the subject.
- */
-fun extractWtidFromSubject(rawSubject: String): String? {
- val re = "\\b[a-z0-9A-Z]{52}\\b".toRegex()
- val result = re.find(rawSubject) ?: return null
- return result.value.toUpperCase()
-}
-
private fun getTalerFacadeState(fcid: String): TalerFacadeStateEntity {
val facade = FacadeEntity.find { FacadesTable.id eq fcid }.firstOrNull() ?: throw NexusError(
HttpStatusCode.NotFound,