commit 9293ac16329d27ff89a7c5f840d7b282cfcbda61 parent c23e315bdbeac2922e32da4a58f709af53dedb41 Author: Antoine A <> Date: Sat, 13 Dec 2025 11:00:30 +0100 ebisync: add submit logic Diffstat:
21 files changed, 977 insertions(+), 115 deletions(-)
diff --git a/Makefile b/Makefile @@ -56,12 +56,14 @@ install-nobuild-files: install -D -t $(bin_dir) contrib/libeufin-dbconfig install -D -t $(bin_dir) contrib/libeufin-ebisync-dbconfig install -D -t $(bin_dir) contrib/libeufin-tan-*.sh + install -d $(share_dir)/libeufin/spa + cp contrib/wallet-core/bank/* $(share_dir)/libeufin/spa/ + install -d $(share_dir)/libeufin-ebisync/spa + cp libeufin-ebisync/src/spa/* $(share_dir)/libeufin-ebisync/spa/ .PHONY: install install: build install-nobuild-files # Install libeufin-bank - install -d $(share_dir)/libeufin/spa - cp contrib/wallet-core/bank/* $(share_dir)/libeufin/spa/ install -D -t $(bin_dir) libeufin-bank/build/install/libeufin-bank-shadow/bin/libeufin-bank install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-bank.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/libeufin-bank.conf.5 diff --git a/debian/libeufin-ebisync.install b/debian/libeufin-ebisync.install @@ -6,6 +6,8 @@ contrib/libeufin-ebisync-dbconfig usr/bin/ database-versioning/versioning.sql usr/share/libeufin-ebisync/sql/ database-versioning/libeufin-ebisync*.sql usr/share/libeufin-ebisync/sql/ +libeufin-ebisync/src/spa/* usr/share/libeufin-ebisync/spa + libeufin-ebisync/ebisync.conf usr/share/libeufin-ebisync/config.d/ libeufin-ebisync/build/install/libeufin-ebisync-shadow/lib/libeufin-ebisync-all.jar usr/lib/ diff --git a/debian/libeufin-ebisync.libeufin-ebisync-httpd.service b/debian/libeufin-ebisync.libeufin-ebisync-httpd.service @@ -0,0 +1,20 @@ +[Unit] +Description=LibEuFin EbiSync Server Service +After=postgres.service network.target +PartOf=libeufin-ebisync.target + +[Service] +User=libeufin-ebisync +ExecStart=/usr/bin/libeufin-ebisync serve -c /etc/libeufin-ebisync/libeufin-ebisync.conf +ExecCondition=/usr/bin/libeufin-ebisync serve -c /etc/libeufin-ebisync/libeufin-ebisync.conf --check +Restart=on-failure +RestartSec=1s +StandardOutput=journal +StandardError=journal +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=full +Slice=libeufin-ebisync.slice + +[Install] +WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules @@ -41,6 +41,7 @@ override_dh_installsystemd: dh_installsystemd -plibeufin-nexus --name=libeufin-nexus-httpd --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -plibeufin-nexus --name=libeufin-nexus --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -plibeufin-ebisync --name=libeufin-ebisync-fetch --no-start --no-enable --no-stop-on-upgrade + dh_installsystemd -plibeufin-ebisync --name=libeufin-ebisync-httpd --no-start --no-enable --no-stop-on-upgrade dh_installsystemd -plibeufin-ebisync --name=libeufin-ebisync --no-start --no-enable --no-stop-on-upgrade # final invocation to generate daemon reload dh_installsystemd diff --git a/libeufin-common/src/main/kotlin/ApiError.kt b/libeufin-common/src/main/kotlin/ApiError.kt @@ -153,4 +153,9 @@ fun notImplemented( fun bodyOverflow( hint: String, error: TalerErrorCode = TalerErrorCode.GENERIC_UPLOAD_EXCEEDS_LIMIT, +): ApiException = apiError(HttpStatusCode.PayloadTooLarge, hint, error) + +fun badGateway( + hint: String, + error: TalerErrorCode = TalerErrorCode.GENERIC_UPLOAD_EXCEEDS_LIMIT, ): ApiException = apiError(HttpStatusCode.PayloadTooLarge, hint, error) \ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/Config.kt b/libeufin-common/src/main/kotlin/Config.kt @@ -1,33 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.common - -sealed interface ServerConfig { - data class Unix(val path: String): ServerConfig - data class Tcp(val addr: String, val port: Int): ServerConfig -} - -fun TalerConfig.loadServerConfig(section: String): ServerConfig { - val sect = section(section) - return sect.mapLambda("serve", "server method", mapOf( - "tcp" to { ServerConfig.Tcp(sect.string("address").orNull() ?: sect.string("bind_to").require(), sect.number("port").require()) }, - "unix" to { ServerConfig.Unix(sect.string("unixpath").require()) } - )).require() -} -\ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/api/auth.kt b/libeufin-common/src/main/kotlin/api/auth.kt @@ -0,0 +1,58 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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.common.api + +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.common.* +import tech.libeufin.common.api.intercept + +/** Apply authentication api configuration for a route */ +fun Route.apiAuth(auth: AuthMethod, callback: Route.() -> Unit): Route = + intercept("Auth", callback) { + if (auth != AuthMethod.None) { + val header = this.request.headers[HttpHeaders.Authorization] + val (expectedScheme, token) = when (auth) { + is AuthMethod.Basic -> "Basic" to auth.token + is AuthMethod.Bearer -> "Bearer" to auth.token + else -> throw UnsupportedOperationException() + } + + if (header == null) { + this.response.header(HttpHeaders.WWWAuthenticate, expectedScheme) + throw unauthorized( + "Authorization header not found", + TalerErrorCode.GENERIC_PARAMETER_MISSING + ) + } + val (scheme, content) = header.splitOnce(" ") ?: throw badRequest( + "Authorization is invalid", + TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED + ) + if (scheme == expectedScheme) { + if (content != token) { + throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN) + } + } else { + throw unauthorized("Expected scheme $expectedScheme got '$scheme'") + } + } + } +\ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/api/server.kt b/libeufin-common/src/main/kotlin/api/server.kt @@ -52,6 +52,9 @@ import java.util.zip.Inflater /** Used to store the raw body */ private val RAW_BODY = AttributeKey<ByteArray>("RAW_BODY") +/** Used to set custom body limit */ +val BODY_LIMIT = AttributeKey<Int>("BODY_LIMIT") + /** Get call raw body */ val ApplicationCall.rawBody: ByteArray get() = attributes.getOrNull(RAW_BODY) ?: ByteArray(0) @@ -88,14 +91,15 @@ fun talerPlugin(logger: Logger): ApplicationPlugin<Unit> { logger.info(requestCall) } onCallReceive { call -> + val bodyLimit = call.attributes.getOrNull(BODY_LIMIT) ?: MAX_BODY_LENGTH // Check content length if present and wellformed val contentLenght = call.request.headers[HttpHeaders.ContentLength]?.toIntOrNull() - if (contentLenght != null && contentLenght > MAX_BODY_LENGTH) - throw bodyOverflow("Body is suspiciously big > ${MAX_BODY_LENGTH}B") + if (contentLenght != null && contentLenght > bodyLimit) + throw bodyOverflow("Body is suspiciously big > ${bodyLimit}B") // Else check while reading and decompressing the body transformBody { body -> - val bytes = ByteArray(MAX_BODY_LENGTH + 1) + val bytes = ByteArray(bodyLimit + 1) var read = 0 when (val encoding = call.request.headers[HttpHeaders.ContentEncoding]) { "deflate" -> { @@ -114,8 +118,8 @@ fun talerPlugin(logger: Logger): ApplicationPlugin<Unit> { ) } } - if (read > MAX_BODY_LENGTH) - throw bodyOverflow("Decompressed body is suspiciously big > ${MAX_BODY_LENGTH}B") + if (read > bodyLimit) + throw bodyOverflow("Decompressed body is suspiciously big > ${bodyLimit}B") } } null -> { @@ -124,8 +128,8 @@ fun talerPlugin(logger: Logger): ApplicationPlugin<Unit> { val new = body.readAvailable(bytes, read, bytes.size - read) if (new == -1) break // Channel is closed read += new - if (read > MAX_BODY_LENGTH) - throw bodyOverflow("Body is suspiciously big > ${MAX_BODY_LENGTH}B") + if (read > bodyLimit) + throw bodyOverflow("Body is suspiciously big > ${bodyLimit}B") } } else -> throw unsupportedMediaType( diff --git a/libeufin-common/src/main/kotlin/config.kt b/libeufin-common/src/main/kotlin/config.kt @@ -0,0 +1,64 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.common + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-config") + +sealed interface ServerConfig { + data class Unix(val path: String): ServerConfig + data class Tcp(val addr: String, val port: Int): ServerConfig +} + +fun TalerConfig.loadServerConfig(section: String): ServerConfig { + val sect = section(section) + return sect.mapLambda("serve", "server method", mapOf( + "tcp" to { ServerConfig.Tcp(sect.string("address").orNull() ?: sect.string("bind_to").require(), sect.number("port").require()) }, + "unix" to { ServerConfig.Unix(sect.string("unixpath").require()) } + )).require() +} + +fun TalerConfigSection.requireAuthMethod(): AuthMethod { + return mapLambda("auth_method", "auth method", mapOf( + "none" to { AuthMethod.None }, + "bearer-token" to { + logger.warn("Deprecated auth method option 'auth_method' used deprecated value 'bearer-token'") + val token = string("auth_bearer_token").require() + AuthMethod.Bearer(token) + }, + "bearer" to { + val token = string("token").require() + AuthMethod.Bearer(token) + }, + "basic" to { + val username = string("username").require() + val password = string("password").require() + AuthMethod.Basic("$username:$password".encodeBase64()) + } + )).require() +} + +sealed interface AuthMethod { + data object None: AuthMethod + data class Bearer(val token: String): AuthMethod + data class Basic(val token: String): AuthMethod +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsConstants.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsConstants.kt @@ -42,11 +42,16 @@ enum class EbicsReturnCode(val code: String) { EBICS_INVALID_ORDER_IDENTIFIER("091005"), EBICS_UNSUPPORTED_ORDER_TYPE("091006"), EBICS_INVALID_XML("091010"), - EBICS_TX_MESSAGE_REPLAY("091103"), - EBICS_TX_SEGMENT_NUMBER_EXCEEDED("091104"), EBICS_TX_UNKNOWN_TXID("091101"), + EBICS_TX_ABORT("091102"), + EBICS_TX_MESSAGE_REPLAY("091102"), + EBICS_TX_SEGMENT_NUMBER_EXCEEDED("091104"), + EBICS_INVALID_ORDER_PARAMS("091112"), EBICS_INVALID_REQUEST_CONTENT("091113"), + EBICS_ORDERID_UNKNOWN("091114"), + EBICS_ORDERID_ALREADY_FINAL("091115"), EBICS_PROCESSING_ERROR("091116"), + EBICS_ORDER_ALREADY_EXISTS("091122"), // Key-Management errors EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE("091201"), diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/order.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/order.kt @@ -101,7 +101,7 @@ sealed class EbicsOrder(val schema: String) { } } - /** Check if EBICS order is a downloadable file */ + /** Check if EBICS order is a downloadable one */ fun isDownload(): Boolean = when (this) { is V2_5 -> this.type in setOf("HAC", "Z01", "Z52", "Z53", "Z54") is V3 -> this.type == "HAC" || ( @@ -109,6 +109,12 @@ sealed class EbicsOrder(val schema: String) { ) } + /** Check if EBICS order is an uploadable one */ + fun isUpload(): Boolean = when (this) { + is V2_5 -> false + is V3 -> this.type == "BTU" + } + /** Check if two EBICS order match ignoring the message version */ fun match(other: EbicsOrder): Boolean = when (this) { is V2_5 -> other is V2_5 diff --git a/libeufin-ebisync/ebisync.conf b/libeufin-ebisync/ebisync.conf @@ -52,6 +52,41 @@ DESTINATION = none # Which Azure Blob Storage container to use for azure-blob-storage # AZURE_COUNTAINER = mycontainer +[ebisync-submit] +# Where does the ebics file come from? This his can either can be sync-api or none +SOURCE = none + +# Authentication scheme used by the API, this can either can be basic, bearer or none. +# AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +# TOKEN = + +[ebisync-httpd] +# How "libeufin-ebisync serve" serves its API, this can either be tcp or unix +SERVE = tcp + +# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp. +PORT = 8080 + +# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Can also be given as a hostname. Only used if SERVE is tcp. +BIND_TO = 0.0.0.0 + +# Which unix domain path should we bind to? Only used if SERVE is unix. +# UNIXPATH = libeufin-ebisync.sock + +# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. +# UNIXPATH_MODE = 660 + +# Path to spa files +SPA = $DATADIR/spa + [ebisyncdb-postgres] # Where are the SQL files to setup our tables? SQL_DIR = $DATADIR/sql/ diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/api/SyncApi.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/api/SyncApi.kt @@ -0,0 +1,118 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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.ebisync.api + +import kotlinx.serialization.Serializable +import tech.libeufin.common.* +import tech.libeufin.common.api.* +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.* +import tech.libeufin.ebisync.db.Database +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.http.content.* +import io.ktor.http.content.* +import io.ktor.http.* +import java.nio.file.Path +import tech.libeufin.common.VERSION + +@Serializable +class TalerEbiSyncConfig() { + val name: String = "taler-observability" + val version: String = "0:0:0" + val spaVersion: String = VERSION +} + +@Serializable +data class SyncOrder( + val id: String, + val description: String +) + +@Serializable +data class SyncSubmit( + val order: String +) + +fun Routing.syncApi(auth: AuthMethod, client: EbicsClient, spa: Path) { + suspend fun orders() = client.download(EbicsOrder.V3.HKD) { stream -> + val hkd = EbicsAdministrative.parseHKD(stream) + hkd.partner.orders + .filter { it.order.isUpload() } + } + + get("/config") { + call.respond(TalerEbiSyncConfig()) + } + apiAuth(auth) { + get("/") { + call.respondRedirect("/webui/") + } + staticFiles("/webui/", spa.toFile()) + get("/submit") { + call.respond(orders().map { SyncOrder(it.order.description(), it.description) }) + } + post("/submit") { + call.attributes.set(BODY_LIMIT, 10 * 1024 * 1024) + val multipart = call.receiveMultipart() + var orderId: String? = null + var xml: ByteArray? = null + + multipart.forEachPart { part -> + when (part) { + is PartData.FormItem -> { + if (part.name == "order") { + orderId = part.value + } + } + is PartData.FileItem -> { + xml = part.streamProvider().readBytes() + } + else -> {} + } + part.dispose() + } + + if (xml == null) { + throw badRequest("Missing file", TalerErrorCode.GENERIC_PARAMETER_MISSING) + } else if (orderId == null) { + throw badRequest("Missing orderId", TalerErrorCode.GENERIC_PARAMETER_MISSING) + } + val match = orders().find { it.order.description() == orderId } ?: throw notFound( + "Unknown order '$orderId'", TalerErrorCode.END + ) + + val order = try { + client.upload(match.order, xml) + } catch (e: Exception) { + if (e is EbicsError.Code) { + throw conflict(e.fmt(), TalerErrorCode.END) + } else if (e is EbicsError) { + throw badGateway(e.fmt(), TalerErrorCode.END) + } else { + throw e + } + } + call.respond(SyncSubmit(order)) + } + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt @@ -122,7 +122,7 @@ class Fetch : EbicsCmd() { val client = EbicsClient( cfg, - httpClient(), + httpClient, db.ebics, EbicsLogger(ebicsLog), clientKeys, diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/LibeufinEbisync.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/LibeufinEbisync.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2025 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 @@ -47,7 +47,7 @@ abstract class EbicsCmd(name: String? = null): TalerCmd(name) { class LibeufinEbisync : CliktCommand() { init { versionOption(VERSION) - subcommands(DbInit(), Setup(), Fetch(), CliConfigCmd(EBISYNC_CONFIG_SOURCE)) + subcommands(DbInit(), Setup(), Fetch(), Serve(), CliConfigCmd(EBISYNC_CONFIG_SOURCE)) } override fun run() = Unit } \ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Serve.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Serve.kt @@ -0,0 +1,84 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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.ebisync.cli + +import io.ktor.server.application.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.* +import tech.libeufin.ebisync.db.Database +import tech.libeufin.ebisync.api.* +import tech.libeufin.common.* +import tech.libeufin.common.api.* +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import java.nio.file.Path + + +fun Application.ebisyncApi(auth: AuthMethod, client: EbicsClient, spa: Path) = talerApi(LoggerFactory.getLogger("libeufin-ebisync-api")) { + syncApi(auth, client, spa) +} + + +class Serve : EbicsCmd() { + override fun help(context: Context) = "Run libeufin-ebisync HTTP server" + + private val check by option( + help = "Check whether an API is in use (if it's useful to start the HTTP server). Exit with 0 if at least one API is enabled, otherwise 1" + ).flag() + + override fun run() = cliCmd(logger) { + val cfg = ebisyncConfig(config) + val auth = when (val source = cfg.submit.source) { + Source.None -> null + is Source.SyncAPI -> source.auth + } + if (check) { + if (auth == null) { + logger.info("No source api, not starting the server") + throw ProgramResult(1) + } else { + throw ProgramResult(0) + } + } else if (auth == null) { + throw ProgramResult(0) + } + + cfg.withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val client = EbicsClient( + cfg, + httpClient(), + db.ebics, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + serve(cfg.serverCfg) { + ebisyncApi(auth, client, cfg.spa) + } + } + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/config.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/config.kt @@ -60,6 +60,14 @@ class EbisyncConfig internal constructor (val cfg: TalerConfig): EbicsKeysConfig val setup by lazy { EbisyncSetupConfig(cfg) } val dbCfg by lazy { cfg.dbConfig() } val fetch by lazy { EbisyncFetchConfig(cfg) } + val submit by lazy { EbisyncSubmitConfig(cfg) } + val serverCfg by lazy { + cfg.loadServerConfig("ebisync-httpd") + } + val spa by lazy { + val sect = cfg.section("ebisync-httpd") + sect.path("SPA").require() + } } class EbisyncFetchConfig(cfg: TalerConfig) { @@ -69,7 +77,7 @@ class EbisyncFetchConfig(cfg: TalerConfig) { val frequencyRaw = sect.string("frequency").require() val checkpointTime = sect.time("checkpoint_time_of_day").require() - val destination = sect.mapLambda("destination", "auth destination", mapOf( + val destination = sect.mapLambda("destination", "ebics file destination", mapOf( "none" to { Destination.None }, "azure-blob-storage" to { Destination.AzureBlobStorage( @@ -82,6 +90,15 @@ class EbisyncFetchConfig(cfg: TalerConfig) { )).require() } +class EbisyncSubmitConfig(cfg: TalerConfig) { + private val sect = cfg.section("ebisync-submit") + + val source = sect.mapLambda("source", "ebics file source", mapOf( + "none" to { Source.None }, + "sync-api" to { Source.SyncAPI(sect.requireAuthMethod()) } + )).require() +} + private fun TalerConfig.dbConfig(): DatabaseConfig { val sect = section("ebisyncdb-postgres") return DatabaseConfig( @@ -90,13 +107,13 @@ private fun TalerConfig.dbConfig(): DatabaseConfig { ) } -/** Load nexus cfg at [configPath] */ +/** Load ebisync cfg at [configPath] */ fun ebisyncConfig(configPath: Path?): EbisyncConfig { val cfg = EBISYNC_CONFIG_SOURCE.fromFile(configPath) return EbisyncConfig(cfg) } -/** Load nexus db cfg at [configPath] */ +/** Load ebisync db cfg at [configPath] */ fun dbConfig(configPath: Path?): DatabaseConfig = EBISYNC_CONFIG_SOURCE.fromFile(configPath).dbConfig() @@ -113,4 +130,9 @@ sealed interface Destination { val accountKey: String, val container: String, ): Destination +} + +sealed interface Source { + data object None: Source + data class SyncAPI(val auth: AuthMethod): Source } \ No newline at end of file diff --git a/libeufin-ebisync/src/spa/index.html b/libeufin-ebisync/src/spa/index.html @@ -0,0 +1,511 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>LibEuFin EbiSync - File Submission Portal</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;600;700&family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet"> + <style> + :root { + --cream: #FAF8F3; + --charcoal: #2B2B2B; + --rust: #C1503D; + --sage: #8B9A7E; + --gold: #D4AF37; + --shadow: rgba(43, 43, 43, 0.08); + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: 'Montserrat', sans-serif; + background: var(--cream); + color: var(--charcoal); + line-height: 1.7; + overflow-x: hidden; + } + + /* Animated background texture */ + body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(43, 43, 43, 0.015) 2px, rgba(43, 43, 43, 0.015) 4px), + repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(43, 43, 43, 0.015) 2px, rgba(43, 43, 43, 0.015) 4px); + pointer-events: none; + z-index: 0; + } + + .container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + position: relative; + z-index: 1; + } + + header { + padding: 2rem 0 2rem; + } + + h1 { + font-family: 'Cormorant Garamond', serif; + font-size: 4.5rem; + font-weight: 300; + letter-spacing: -0.02em; + line-height: 1.1; + margin-bottom: 1rem; + } + + .subtitle { + font-size: 1rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--rust); + font-weight: 500; + } + + .version-text { + margin-left: auto; + font-size: 0.75rem; + color: rgba(43, 43, 43, 0.5); + font-family: 'Courier New', monospace; + } + + main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + margin-bottom: 6rem; + } + + section { + background: white; + padding: 1rem 2rem; + border: 1px solid rgba(43, 43, 43, 0.1); + position: relative; + transition: all 0.4s ease; + } + + section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--rust); + transform: scaleY(0); + transition: transform 0.4s ease; + } + + section:hover::before { + transform: scaleY(1); + } + + h2 { + font-family: 'Cormorant Garamond', serif; + font-size: 2.5rem; + font-weight: 400; + margin-bottom: 0.5rem; + letter-spacing: -0.01em; + } + + .order-card { + padding: 1.5rem; + margin-bottom: 1rem; + border: 1px solid rgba(43, 43, 43, 0.1); + cursor: pointer; + transition: all 0.3s ease; + position: relative; + background: var(--cream); + } + + .order-card:hover { + transform: translateX(8px); + border-color: var(--rust); + box-shadow: -8px 0 0 var(--rust); + } + + .order-card.selected { + background: var(--charcoal); + color: var(--cream); + border-color: var(--charcoal); + } + + .order-card.selected .order-id { + color: var(--gold); + } + + .order-id { + font-family: 'Courier New', monospace; + color: var(--rust); + margin-bottom: 0.5rem; + font-weight: bold; + } + + .order-description { + font-size: 0.875rem; + line-height: 1.6; + } + + .file-upload-zone { + padding: 3rem; + border: 2px dashed rgba(43, 43, 43, 0.2); + text-align: center; + cursor: pointer; + position: relative; + } + + .file-upload-zone:hover { + border-color: var(--rust); + background: rgba(193, 80, 61, 0.03); + } + + .file-upload-zone.drag-over { + border-color: var(--sage); + background: rgba(139, 154, 126, 0.1); + transform: scale(1.02); + } + + .upload-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.3; + } + + .upload-text { + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .upload-hint { + font-size: 0.875rem; + opacity: 0.6; + } + + input[type="file"] { + display: none; + } + + .selected-file { + margin-top: 1.5rem; + padding: 1.5rem; + background: var(--cream); + border-left: 4px solid var(--sage); + } + + .selected-file-name { + font-weight: 600; + margin-bottom: 0.5rem; + } + + .selected-file-size { + font-size: 0.875rem; + opacity: 0.7; + } + + .submit-button { + width: 100%; + margin-top: 2rem; + padding: 1.25rem 2rem; + background: var(--charcoal); + color: var(--cream); + border: none; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + } + + .submit-button::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: var(--rust); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; + } + + .submit-button:hover::before { + width: 300%; + height: 300%; + } + + .submit-button:hover { + color: white; + } + + .submit-button span { + position: relative; + z-index: 1; + } + + .submit-button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .message { + margin-top: 2rem; + padding: 1.5rem; + border-left: 4px solid var(--sage); + background: rgba(139, 154, 126, 0.1); + animation: fadeIn 0.5s ease forwards; + } + + .message.error { + border-left-color: var(--rust); + background: rgba(193, 80, 61, 0.1); + } + + .message.success { + border-left-color: var(--sage); + background: rgba(139, 154, 126, 0.1); + } + + .loading { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(43, 43, 43, 0.1); + border-radius: 50%; + border-top-color: var(--charcoal); + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + main { + grid-template-columns: 1fr; + } + + h1 { + font-size: 3rem; + } + } + </style> +</head> +<body> + <div class="container"> + <header> + <div class="subtitle">LibEuFin EbiSync</div> + <h1>File Submission Portal</h1> + <div class="version-text" id="versionText">Initializing...</div> + </header> + + <main> + <section> + <h2>Choose Order</h2> + <div class="orders-list" id="ordersList"> + <div style="text-align: center; padding: 2rem; opacity: 0.5;"> + <div class="loading"></div> + <p style="margin-top: 1rem;">Loading orders...</p> + </div> + </div> + </section> + + <section> + <h2>Submit File</h2> + <div class="file-upload-zone" id="uploadZone"> + <div class="upload-icon">📄</div> + <div class="upload-text">Drop XML file here or click to browse</div> + <div class="upload-hint">Accepts .xml files only</div> + <input type="file" id="fileInput" accept=".xml,application/xml,text/xml"> + </div> + <div id="selectedFileInfo"></div> + <button class="submit-button" id="submitButton" disabled> + <span>Submit Document</span> + </button> + <div id="messageArea"></div> + </section> + </main> + </div> + + <script> + let selectedOrder = null; + let selectedFile = null; + + // Initialize + function init() { + loadConfig() + loadOrders(); + setupEventListeners(); + } + + async function loadConfig() { + try { + const response = await fetch('/config'); + const data = await response.json(); + document.getElementById('versionText').textContent = `${data.spaVersion} (${data.version})`; + } catch (error) { + document.getElementById('versionText').textContent = 'Error'; + showMessage('Unable to connect to backend server', 'error'); + } + } + + async function loadOrders() { + try { + const response = await fetch('/submit'); + const orders = await response.json(); + + const ordersList = document.getElementById('ordersList'); + ordersList.innerHTML = ''; + + orders.forEach(order => { + const orderCard = document.createElement('div'); + orderCard.className = 'order-card'; + orderCard.innerHTML = ` + <div class="order-id">${order.id}</div> + <div class="order-description">${order.description}</div> + `; + orderCard.onclick = () => selectOrder(order, orderCard); + ordersList.appendChild(orderCard); + }); + } catch (error) { + document.getElementById('ordersList').innerHTML = + '<p style="text-align: center; opacity: 0.5;">Failed to load orders</p>'; + } + } + + function selectOrder(order, element) { + document.querySelectorAll('.order-card').forEach(card => { + card.classList.remove('selected'); + }); + element.classList.add('selected'); + selectedOrder = order; + updateSubmitButton(); + } + + function setupEventListeners() { + const uploadZone = document.getElementById('uploadZone'); + const fileInput = document.getElementById('fileInput'); + + uploadZone.addEventListener('click', () => fileInput.click()); + + uploadZone.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadZone.classList.add('drag-over'); + }); + + uploadZone.addEventListener('dragleave', () => { + uploadZone.classList.remove('drag-over'); + }); + + uploadZone.addEventListener('drop', (e) => { + e.preventDefault(); + uploadZone.classList.remove('drag-over'); + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFileSelect(files[0]); + } + }); + + fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + handleFileSelect(e.target.files[0]); + } + }); + + document.getElementById('submitButton').addEventListener('click', submitFile); + } + + function handleFileSelect(file) { + if (!file.name.endsWith('.xml')) { + showMessage('Please select an XML file', 'error'); + return; + } + + selectedFile = file; + const infoDiv = document.getElementById('selectedFileInfo'); + infoDiv.innerHTML = ` + <div class="selected-file"> + <div class="selected-file-name">📎 ${file.name}</div> + <div class="selected-file-size">${(file.size / 1024).toFixed(2)} KB</div> + </div> + `; + updateSubmitButton(); + } + + function updateSubmitButton() { + const button = document.getElementById('submitButton'); + button.disabled = !(selectedOrder && selectedFile); + } + + async function submitFile() { + const button = document.getElementById('submitButton'); + button.disabled = true; + button.innerHTML = '<span><div class="loading" style="display: inline-block; vertical-align: middle; margin-right: 10px;"></div>Submitting...</span>'; + + const formData = new FormData(); + formData.append('order', selectedOrder.id); + formData.append('file', selectedFile); + + clearMessage() + + try { + const response = await fetch('/submit', { + method: 'POST', + body: formData + }); + const data = await response.json(); + if (response.ok) { + showMessage(`Successfully submitted ${selectedFile.name} with order ${selectedOrder.id} as ${data.order}`, 'success'); + // Reset form + selectedFile = null; + document.getElementById('selectedFileInfo').innerHTML = ''; + document.getElementById('fileInput').value = ''; + } else { + showMessage(`${data.code} - ${data.hint ?? 'Submission failed'}`, 'error'); + } + } catch (error) { + showMessage('Network error: ' + error.message, 'error'); + } finally { + button.disabled = false; + button.innerHTML = '<span>Submit Document</span>'; + updateSubmitButton(); + } + } + + function showMessage(text, type = 'info') { + const messageArea = document.getElementById('messageArea'); + messageArea.innerHTML = ` + <div class="message ${type}"> + ${text} + </div> + `; + } + + function clearMessage() { + const messageArea = document.getElementById('messageArea'); + messageArea.innerHTML = ''; + } + + // Start application + init(); + </script> +</body> +</html> +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -157,26 +157,6 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { ) } -private fun TalerConfigSection.requireAuthMethod(): AuthMethod { - return mapLambda("auth_method", "auth method", mapOf( - "none" to { AuthMethod.None }, - "bearer-token" to { - logger.warn("Deprecated auth method option 'auth_method' used deprecated value 'bearer-token'") - val token = string("auth_bearer_token").require() - AuthMethod.Bearer(token) - }, - "bearer" to { - val token = string("token").require() - AuthMethod.Bearer(token) - }, - "basic" to { - val username = string("username").require() - val password = string("password").require() - AuthMethod.Basic("$username:$password".encodeBase64()) - } - )).require() -} - private fun TalerConfigSection.apiConf(): ApiConfig? { val enabled = boolean("enabled").require() return if (enabled) { @@ -186,12 +166,6 @@ private fun TalerConfigSection.apiConf(): ApiConfig? { } } -sealed interface AuthMethod { - data object None: AuthMethod - data class Bearer(val token: String): AuthMethod - data class Basic(val token: String): AuthMethod -} - enum class AccountType { normal, exchange diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt @@ -24,40 +24,18 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import tech.libeufin.common.* import tech.libeufin.common.api.intercept +import tech.libeufin.common.api.apiAuth import tech.libeufin.nexus.ApiConfig -import tech.libeufin.nexus.AuthMethod /** Apply authentication api configuration for a route */ -fun Route.auth(cfg: ApiConfig?, callback: Route.() -> Unit): Route = - intercept("Auth", callback) { - if (cfg?.authMethod != AuthMethod.None) { - val header = this.request.headers[HttpHeaders.Authorization] - val (expectedScheme, token) = when (val method = cfg?.authMethod) { - is AuthMethod.Basic -> "Basic" to method.token - is AuthMethod.Bearer -> "Bearer" to method.token - else -> throw UnsupportedOperationException() - } - - if (header == null) { - this.response.header(HttpHeaders.WWWAuthenticate, expectedScheme) - throw unauthorized( - "Authorization header not found", - TalerErrorCode.GENERIC_PARAMETER_MISSING - ) - } - val (scheme, content) = header.splitOnce(" ") ?: throw badRequest( - "Authorization is invalid", - TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED - ) - if (scheme == expectedScheme) { - if (content != token) { - throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN) - } - } else { - throw unauthorized("Expected scheme $expectedScheme got '$scheme'") - } - } +fun Route.auth(cfg: ApiConfig?, callback: Route.() -> Unit): Route { + val method = cfg?.authMethod + if (method != null) { + return apiAuth(method, callback) + } else { + return this } +} /** Apply conditional api configuration for a route */ fun Route.conditional(cfg: ApiConfig?, callback: Route.() -> Unit): Route = diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -133,19 +133,21 @@ class Cli : CliktCommand() { [ebisync-fetch] FREQUENCY = 1h CHECKPOINT_TIME_OF_DAY = 16:52 + DESTINATION = azure-blob-storage + AZURE_API_URL = http://localhost:10000/devstoreaccount1/ + AZURE_ACCOUNT_NAME = devstoreaccount1 + AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + AZURE_CONTAINER = test + + [ebisync-submit] + SOURCE = sync-api + AUTH_METHOD = none [libeufin-nexusdb-postgres] CONFIG = postgres:///libeufintestbench [ebisyncdb-postgres] CONFIG = postgres:///libeufintestbench - - [ebisync-fetch] - DESTINATION = azure-blob-storage - AZURE_API_URL = http://localhost:10000/devstoreaccount1/ - AZURE_ACCOUNT_NAME = devstoreaccount1 - AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== - AZURE_CONTAINER = test """) // Prepare shell