commit f214ac079dae5a93e8716bf0349f8e70b5df0957
parent 38fc8731ba5bfbae3a195ee8845aea9779a7802e
Author: Florian Dold <florian.dold@gmail.com>
Date: Fri, 19 Jun 2020 12:21:07 +0530
refactor, towards common interface for bank protocols
Diffstat:
11 files changed, 1273 insertions(+), 1104 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt 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
@@ -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
@@ -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/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt
@@ -1,301 +0,0 @@
-/*
- * 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 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.util.*
-import java.time.LocalDate
-import java.time.LocalDateTime
-
-data class BackupRequestJson(
- val passphrase: String
-)
-
-@JsonTypeInfo(
- use = JsonTypeInfo.Id.NAME,
- include = JsonTypeInfo.As.PROPERTY,
- property = "paramType"
-)
-@JsonSubTypes(
- JsonSubTypes.Type(value = EbicsStandardOrderParamsDateJson::class, name = "standard-date-range"),
- JsonSubTypes.Type(value = EbicsStandardOrderParamsEmptyJson::class, name = "standard-empty"),
- JsonSubTypes.Type(value = EbicsGenericOrderParamsJson::class, name = "generic")
-)
-abstract class EbicsOrderParamsJson {
- abstract fun toOrderParams(): EbicsOrderParams
-}
-
-@JsonTypeName("generic")
-class EbicsGenericOrderParamsJson(
- val params: Map<String, String>
-) : EbicsOrderParamsJson() {
- override fun toOrderParams(): EbicsOrderParams {
- return EbicsGenericOrderParams(params)
- }
-}
-
-@JsonTypeName("standard-empty")
-class EbicsStandardOrderParamsEmptyJson : EbicsOrderParamsJson() {
- override fun toOrderParams(): EbicsOrderParams {
- return EbicsStandardOrderParams(null)
- }
-}
-
-@JsonTypeName("standard-date-range")
-class EbicsStandardOrderParamsDateJson(
- val start: String,
- val end: String
-) : EbicsOrderParamsJson() {
- override fun toOrderParams(): EbicsOrderParams {
- val dateRange: EbicsDateRange? =
- EbicsDateRange(
- LocalDate.parse(this.start),
- LocalDate.parse(this.end)
- )
- return EbicsStandardOrderParams(dateRange)
- }
-}
-
-data class EbicsErrorDetailJson(
- val type: String,
- val ebicsReturnCode: String
-)
-
-data class EbicsErrorJson(
- val error: EbicsErrorDetailJson
-)
-
-data class BankConnectionInfo(
- val name: String,
- val type: String
-)
-
-data class BankConnectionsList(
- val bankConnections: List<BankConnectionInfo>
-)
-
-data class EbicsHostTestRequest(
- val ebicsBaseUrl: String,
- val ebicsHostId: String
-)
-
-/**
- * This object is used twice: as a response to the backup request,
- * and as a request to the backup restore. Note: in the second case
- * the client must provide the passphrase.
- */
-data class EbicsKeysBackupJson(
- // Always "ebics"
- val type: String,
- val userID: String,
- val partnerID: String,
- val hostID: String,
- val ebicsURL: String,
- val authBlob: String,
- val encBlob: String,
- val sigBlob: String
-)
-
-enum class FetchLevel(@get:JsonValue val jsonName: String) {
- REPORT("report"), STATEMENT("statement"), ALL("all");
-}
-
-/**
- * Instructions on what range to fetch from the bank,
- * and which source(s) to use.
- *
- * Intended to be convenient to specify.
- */
-@JsonTypeInfo(
- use = JsonTypeInfo.Id.NAME,
- include = JsonTypeInfo.As.PROPERTY,
- property = "rangeType"
-)
-@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 = FetchSpecSinceLastJson::class, name = "since-last")
-)
-abstract class FetchSpecJson(
- val level: FetchLevel,
- val bankConnection: String?
-)
-
-@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)
-
-@JsonTypeInfo(
- use = JsonTypeInfo.Id.NAME,
- include = JsonTypeInfo.As.PROPERTY,
- property = "source"
-)
-@JsonSubTypes(
- JsonSubTypes.Type(value = CreateBankConnectionFromBackupRequestJson::class, name = "backup"),
- JsonSubTypes.Type(value = CreateBankConnectionFromNewRequestJson::class, name = "new")
-)
-abstract class CreateBankConnectionRequestJson(
- val name: String
-)
-
-@JsonTypeName("backup")
-class CreateBankConnectionFromBackupRequestJson(
- name: String,
- val passphrase: String?,
- val data: JsonNode
-) : CreateBankConnectionRequestJson(name)
-
-@JsonTypeName("new")
-class CreateBankConnectionFromNewRequestJson(
- name: String,
- val type: String,
- val data: JsonNode
-) : CreateBankConnectionRequestJson(name)
-
-data class EbicsNewTransport(
- val userID: String,
- val partnerID: String,
- val hostID: String,
- val ebicsURL: String,
- val systemID: String?
-)
-
-/** Response type of "GET /prepared-payments/{uuid}" */
-data class PaymentStatus(
- val paymentInitiationId: String,
- val submitted: Boolean,
- val creditorIban: String,
- val creditorBic: String?,
- val creditorName: String,
- val amount: String,
- val subject: String,
- val submissionDate: String?,
- val preparationDate: String
-)
-
-data class Transactions(
- val transactions: MutableList<BankTransaction> = mutableListOf()
-)
-
-data class BankProtocolsResponse(
- val protocols: List<String>
-)
-
-/** Request type of "POST /prepared-payments" */
-data class CreatePaymentInitiationRequest(
- val iban: String,
- val bic: String?,
- val name: String,
- val amount: String,
- val subject: String
-)
-
-/** Response type of "POST /prepared-payments" */
-data class PaymentInitiationResponse(
- val uuid: String
-)
-
-/** Response type of "GET /user" */
-data class UserResponse(
- val username: String,
- val superuser: Boolean
-)
-
-/** Request type of "POST /users" */
-data class User(
- val username: String,
- val password: String
-)
-
-data class UserInfo(
- val username: String,
- val superuser: Boolean
-)
-
-data class UsersResponse(
- val users: List<UserInfo>
-)
-
-/** Response (list's element) type of "GET /bank-accounts" */
-data class BankAccount(
- var holder: String,
- var iban: String,
- var bic: String,
- var account: String
-)
-
-/** Response type of "GET /bank-accounts" */
-data class BankAccounts(
- var accounts: MutableList<BankAccount> = mutableListOf()
-)
-
-data class BankMessageList(
- val bankMessages: MutableList<BankMessageInfo> = mutableListOf()
-)
-
-data class BankMessageInfo(
- val messageId: String,
- val code: String,
- val length: Long
-)
-
-data class FacadeInfo(
- val name: String,
- val type: String,
- val creator: String,
- val bankAccountsRead: MutableList<String>? = mutableListOf(),
- val bankAccountsWrite: MutableList<String>? = mutableListOf(),
- val bankConnectionsRead: MutableList<String>? = mutableListOf(),
- val bankConnectionsWrite: MutableList<String>? = mutableListOf(),
- val config: TalerWireGatewayFacadeConfig /* To be abstracted to Any! */
-)
-
-data class TalerWireGatewayFacadeConfig(
- val bankAccount: String,
- val bankConnection: String,
- val reserveTransferLevel: String,
- val intervalIncremental: String
-)
-
-/**********************************************************************
- * Convenience types (ONLY used to gather data together in one place) *
- **********************************************************************/
-
-data class Pain001Data(
- val creditorIban: String,
- val creditorBic: String?,
- val creditorName: String,
- val sum: Amount,
- val currency: String,
- val subject: String
-)
-
-
diff --git 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
@@ -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
@@ -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
@@ -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/server/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
@@ -0,0 +1,304 @@
+/*
+ * 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.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
+
+data class BackupRequestJson(
+ val passphrase: String
+)
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "paramType"
+)
+@JsonSubTypes(
+ JsonSubTypes.Type(value = EbicsStandardOrderParamsDateJson::class, name = "standard-date-range"),
+ JsonSubTypes.Type(value = EbicsStandardOrderParamsEmptyJson::class, name = "standard-empty"),
+ JsonSubTypes.Type(value = EbicsGenericOrderParamsJson::class, name = "generic")
+)
+abstract class EbicsOrderParamsJson {
+ abstract fun toOrderParams(): EbicsOrderParams
+}
+
+@JsonTypeName("generic")
+class EbicsGenericOrderParamsJson(
+ val params: Map<String, String>
+) : EbicsOrderParamsJson() {
+ override fun toOrderParams(): EbicsOrderParams {
+ return EbicsGenericOrderParams(params)
+ }
+}
+
+@JsonTypeName("standard-empty")
+class EbicsStandardOrderParamsEmptyJson : EbicsOrderParamsJson() {
+ override fun toOrderParams(): EbicsOrderParams {
+ return EbicsStandardOrderParams(null)
+ }
+}
+
+@JsonTypeName("standard-date-range")
+class EbicsStandardOrderParamsDateJson(
+ val start: String,
+ val end: String
+) : EbicsOrderParamsJson() {
+ override fun toOrderParams(): EbicsOrderParams {
+ val dateRange: EbicsDateRange? =
+ EbicsDateRange(
+ LocalDate.parse(this.start),
+ LocalDate.parse(this.end)
+ )
+ return EbicsStandardOrderParams(dateRange)
+ }
+}
+
+data class EbicsErrorDetailJson(
+ val type: String,
+ val ebicsReturnCode: String
+)
+
+data class EbicsErrorJson(
+ val error: EbicsErrorDetailJson
+)
+
+data class BankConnectionInfo(
+ val name: String,
+ val type: String
+)
+
+data class BankConnectionsList(
+ val bankConnections: List<BankConnectionInfo>
+)
+
+data class EbicsHostTestRequest(
+ val ebicsBaseUrl: String,
+ val ebicsHostId: String
+)
+
+/**
+ * This object is used twice: as a response to the backup request,
+ * and as a request to the backup restore. Note: in the second case
+ * the client must provide the passphrase.
+ */
+data class EbicsKeysBackupJson(
+ // Always "ebics"
+ val type: String,
+ val userID: String,
+ val partnerID: String,
+ val hostID: String,
+ val ebicsURL: String,
+ val authBlob: String,
+ val encBlob: String,
+ val sigBlob: String
+)
+
+enum class FetchLevel(@get:JsonValue val jsonName: String) {
+ REPORT("report"), STATEMENT("statement"), ALL("all");
+}
+
+/**
+ * Instructions on what range to fetch from the bank,
+ * and which source(s) to use.
+ *
+ * Intended to be convenient to specify.
+ */
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "rangeType"
+)
+@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 = FetchSpecSinceLastJson::class, name = "since-last")
+)
+abstract class FetchSpecJson(
+ val level: FetchLevel,
+ val bankConnection: String?
+)
+
+@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)
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "source"
+)
+@JsonSubTypes(
+ JsonSubTypes.Type(value = CreateBankConnectionFromBackupRequestJson::class, name = "backup"),
+ JsonSubTypes.Type(value = CreateBankConnectionFromNewRequestJson::class, name = "new")
+)
+abstract class CreateBankConnectionRequestJson(
+ val name: String
+)
+
+@JsonTypeName("backup")
+class CreateBankConnectionFromBackupRequestJson(
+ name: String,
+ val passphrase: String?,
+ val data: JsonNode
+) : CreateBankConnectionRequestJson(name)
+
+@JsonTypeName("new")
+class CreateBankConnectionFromNewRequestJson(
+ name: String,
+ val type: String,
+ val data: JsonNode
+) : CreateBankConnectionRequestJson(name)
+
+data class EbicsNewTransport(
+ val userID: String,
+ val partnerID: String,
+ val hostID: String,
+ val ebicsURL: String,
+ val systemID: String?
+)
+
+/** Response type of "GET /prepared-payments/{uuid}" */
+data class PaymentStatus(
+ val paymentInitiationId: String,
+ val submitted: Boolean,
+ val creditorIban: String,
+ val creditorBic: String?,
+ val creditorName: String,
+ val amount: String,
+ val subject: String,
+ val submissionDate: String?,
+ val preparationDate: String
+)
+
+data class Transactions(
+ val transactions: MutableList<BankTransaction> = mutableListOf()
+)
+
+data class BankProtocolsResponse(
+ val protocols: List<String>
+)
+
+/** Request type of "POST /prepared-payments" */
+data class CreatePaymentInitiationRequest(
+ val iban: String,
+ val bic: String?,
+ val name: String,
+ val amount: String,
+ val subject: String
+)
+
+/** Response type of "POST /prepared-payments" */
+data class PaymentInitiationResponse(
+ val uuid: String
+)
+
+/** Response type of "GET /user" */
+data class UserResponse(
+ val username: String,
+ val superuser: Boolean
+)
+
+/** Request type of "POST /users" */
+data class User(
+ val username: String,
+ val password: String
+)
+
+data class UserInfo(
+ val username: String,
+ val superuser: Boolean
+)
+
+data class UsersResponse(
+ val users: List<UserInfo>
+)
+
+/** Response (list's element) type of "GET /bank-accounts" */
+data class BankAccount(
+ var holder: String,
+ var iban: String,
+ var bic: String,
+ var account: String
+)
+
+/** Response type of "GET /bank-accounts" */
+data class BankAccounts(
+ var accounts: MutableList<BankAccount> = mutableListOf()
+)
+
+data class BankMessageList(
+ val bankMessages: MutableList<BankMessageInfo> = mutableListOf()
+)
+
+data class BankMessageInfo(
+ val messageId: String,
+ val code: String,
+ val length: Long
+)
+
+data class FacadeInfo(
+ val name: String,
+ val type: String,
+ val creator: String,
+ val bankAccountsRead: MutableList<String>? = mutableListOf(),
+ val bankAccountsWrite: MutableList<String>? = mutableListOf(),
+ val bankConnectionsRead: MutableList<String>? = mutableListOf(),
+ val bankConnectionsWrite: MutableList<String>? = mutableListOf(),
+ val config: TalerWireGatewayFacadeConfig /* To be abstracted to Any! */
+)
+
+data class TalerWireGatewayFacadeConfig(
+ val bankAccount: String,
+ val bankConnection: String,
+ val reserveTransferLevel: String,
+ val intervalIncremental: String
+)
+
+/**********************************************************************
+ * Convenience types (ONLY used to gather data together in one place) *
+ **********************************************************************/
+
+data class Pain001Data(
+ val creditorIban: String,
+ val creditorBic: String?,
+ val creditorName: String,
+ val sum: Amount,
+ val currency: String,
+ val subject: String
+)
+
+
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt 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
@@ -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,