libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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:
Anexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt | 21+++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/DB.kt | 3+++
Anexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt | 26++++++++++++++++++++++++++
Dnexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 301-------------------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 792+------------------------------------------------------------------------------
Anexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt | 36++++++++++++++++++++++++++++++++++--
Anexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt | 728+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/taler.kt | 18++++++++----------
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,